Merge branch 'main' into detect-language

# Conflicts:
#	app/javascript/mastodon/features/status/components/detailed_status.jsx
This commit is contained in:
Christian Schmidt 2024-09-12 18:33:05 +02:00
commit 22d364e1f6
1130 changed files with 10733 additions and 7081 deletions

View File

@ -1,6 +1,7 @@
[production] [production]
defaults defaults
> 0.2% > 0.2%
firefox >= 78
ios >= 15.6 ios >= 15.6
not dead not dead
not OperaMini all not OperaMini all

View File

@ -11,5 +11,8 @@ RUN apt-get update && \
export DEBIAN_FRONTEND=noninteractive && \ export DEBIAN_FRONTEND=noninteractive && \
apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libvips42 libpam-dev apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libvips42 libpam-dev
# Disable download prompt for Corepack
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# Move welcome message to where VS Code expects it # Move welcome message to where VS Code expects it
COPY .devcontainer/welcome-message.txt /usr/local/etc/vscode-dev-containers/first-run-notice.txt COPY .devcontainer/welcome-message.txt /usr/local/etc/vscode-dev-containers/first-run-notice.txt

View File

@ -39,7 +39,7 @@
}, },
"onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
"postCreateCommand": "COREPACK_ENABLE_DOWNLOAD_PROMPT=0 bin/setup", "postCreateCommand": "bin/setup",
"waitFor": "postCreateCommand", "waitFor": "postCreateCommand",
"customizations": { "customizations": {

View File

@ -69,7 +69,7 @@ services:
hard: -1 hard: -1
libretranslate: libretranslate:
image: libretranslate/libretranslate:v1.5.7 image: libretranslate/libretranslate:v1.6.0
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- lt-data:/home/libretranslate/.local - lt-data:/home/libretranslate/.local

View File

@ -316,7 +316,7 @@ module.exports = defineConfig({
], ],
parserOptions: { parserOptions: {
project: true, projectService: true,
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
}, },

2
.github/codecov.yml vendored
View File

@ -9,3 +9,5 @@ coverage:
default: default:
# GitHub status check is not blocking # GitHub status check is not blocking
informational: true informational: true
github_checks:
annotations: false

View File

@ -7,6 +7,7 @@
':prConcurrentLimitNone', // Remove limit for open PRs at any time. ':prConcurrentLimitNone', // Remove limit for open PRs at any time.
':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour. ':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour.
], ],
rebaseWhen: 'conflicted',
minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it
// packageRules order is important, they are applied from top to bottom and are merged, // packageRules order is important, they are applied from top to bottom and are merged,
// meaning the most important ones must be at the bottom, for example grouping rules // meaning the most important ones must be at the bottom, for example grouping rules

View File

@ -52,7 +52,7 @@ jobs:
# Create or update the pull request # Create or update the pull request
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@v6.0.5 uses: peter-evans/create-pull-request@v7.0.1
with: with:
commit-message: 'New Crowdin translations' commit-message: 'New Crowdin translations'
title: 'New Crowdin Translations (automated)' title: 'New Crowdin Translations (automated)'

View File

@ -42,11 +42,24 @@ jobs:
with: with:
onlyProduction: 'true' onlyProduction: 'true'
- name: Cache assets from compilation
uses: actions/cache@v4
with:
path: |
public/assets
public/packs
public/packs-test
tmp/cache/webpacker
key: ${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
restore-keys: |
${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}
${{ matrix.mode }}-assets-main
${{ matrix.mode }}-assets
- name: Precompile assets - name: Precompile assets
# Previously had set this, but it's not supported
# export NODE_OPTIONS=--openssl-legacy-provider
run: |- run: |-
./bin/rails assets:precompile bin/rails assets:precompile
- name: Archive asset artifacts - name: Archive asset artifacts
run: | run: |
@ -137,6 +150,19 @@ jobs:
bin/rails db:setup bin/rails db:setup
bin/flatware fan bin/rails db:test:prepare bin/flatware fan bin/rails db:test:prepare
- name: Cache RSpec persistence file
uses: actions/cache@v4
with:
path: |
tmp/rspec/examples.txt
key: rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
restore-keys: |
rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }}-${{ matrix.ruby-version }}
rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
rspec-persistence-${{ github.head_ref || github.ref_name }}
rspec-persistence-main
rspec-persistence
- run: bin/flatware rspec -r ./spec/flatware_helper.rb - run: bin/flatware rspec -r ./spec/flatware_helper.rb
- name: Upload coverage reports to Codecov - name: Upload coverage reports to Codecov

3
.gitignore vendored
View File

@ -71,3 +71,6 @@ docker-compose.override.yml
# Ignore dotenv .local files # Ignore dotenv .local files
.env*.local .env*.local
# Ignore local-only rspec configuration
.rspec-local

View File

@ -1 +0,0 @@
LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/app/.apt/lib/x86_64-linux-gnu:/app/.apt/usr/lib/x86_64-linux-gnu/mesa:/app/.apt/usr/lib/x86_64-linux-gnu/pulseaudio:/app/.apt/usr/lib/x86_64-linux-gnu/openblas-pthread

1
.rspec
View File

@ -1,3 +1,2 @@
--color --color
--require spec_helper --require spec_helper
--format Fuubar

View File

@ -1,6 +1,6 @@
# This configuration was generated by # This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.65.0. # using RuboCop version 1.66.1.
# The point is for the user to remove these configuration records # The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base. # one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new # Note that changes in the inspected code, or installation of new
@ -35,7 +35,6 @@ Rails/OutputSafety:
# Configuration parameters: AllowedVars. # Configuration parameters: AllowedVars.
Style/FetchEnvVar: Style/FetchEnvVar:
Exclude: Exclude:
- 'app/lib/redis_configuration.rb'
- 'app/lib/translation_service.rb' - 'app/lib/translation_service.rb'
- 'config/environments/production.rb' - 'config/environments/production.rb'
- 'config/initializers/2_limited_federation_mode.rb' - 'config/initializers/2_limited_federation_mode.rb'
@ -44,7 +43,6 @@ Style/FetchEnvVar:
- 'config/initializers/devise.rb' - 'config/initializers/devise.rb'
- 'config/initializers/paperclip.rb' - 'config/initializers/paperclip.rb'
- 'config/initializers/vapid.rb' - 'config/initializers/vapid.rb'
- 'lib/mastodon/redis_config.rb'
- 'lib/tasks/repo.rake' - 'lib/tasks/repo.rake'
# This cop supports safe autocorrection (--autocorrect). # This cop supports safe autocorrection (--autocorrect).
@ -93,7 +91,6 @@ Style/OptionalBooleanParameter:
- 'app/services/fetch_resource_service.rb' - 'app/services/fetch_resource_service.rb'
- 'app/workers/domain_block_worker.rb' - 'app/workers/domain_block_worker.rb'
- 'app/workers/unfollow_follow_worker.rb' - 'app/workers/unfollow_follow_worker.rb'
- 'lib/mastodon/redis_config.rb'
# This cop supports unsafe autocorrection (--autocorrect-all). # This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: EnforcedStyle. # Configuration parameters: EnforcedStyle.

View File

@ -1 +1 @@
3.3.4 3.3.5

10
Aptfile
View File

@ -1,5 +1,5 @@
ffmpeg libidn12
libopenblas0-pthread # for idn-ruby on heroku-24 stack
libpq-dev
libxdamage1 # use https://github.com/heroku/heroku-buildpack-activestorage-preview
libxfixes3 # in place for ffmpeg and its dependent packages to reduce slag size

View File

@ -165,7 +165,7 @@ The following changelog entries focus on changes visible to users, administrator
- **Change colors throughout the web UI** (#29522, #29584, #29653, #29779, #29803, #29809, #29808, #29828, #31034, #31168, #31266, #31348, #31349, #31361, and #31510 by @ClearlyClaire, @Gargron, @renchap, and @vmstan) - **Change colors throughout the web UI** (#29522, #29584, #29653, #29779, #29803, #29809, #29808, #29828, #31034, #31168, #31266, #31348, #31349, #31361, and #31510 by @ClearlyClaire, @Gargron, @renchap, and @vmstan)
- **Change onboarding prompt to follow suggestions carousel in web UI** (#28878 and #29272 by @Gargron) - **Change onboarding prompt to follow suggestions carousel in web UI** (#28878 and #29272 by @Gargron)
- **Change email templates** (#28416, #28755, #28814, #29064, #28883, #29470, #29607, #29761, #29760, and #29879 by @ClearlyClaire, @Gargron, @hteumeuleu, and @mjankowski)\ - **Change email templates** (#28416, #28755, #28814, #29064, #28883, #29470, #29607, #29761, #29760, and #29879 by @ClearlyClaire, @Gargron, @hteumeuleu, and @mjankowski)\
All emails to end-users have been completely redesigned with a fresh new look, providing more information while making them easier to reand and keeping maximum compatibility across mail clients. All emails to end-users have been completely redesigned with a fresh new look, providing more information while making them easier to read and keeping maximum compatibility across mail clients.
- **Change follow recommendations algorithm** (#28314, #28433, #29017, #29108, #29306, #29550, #29619, and #31474 by @ClearlyClaire, @Gargron, @kernal053, @mjankowski, and @wheatear-dev)\ - **Change follow recommendations algorithm** (#28314, #28433, #29017, #29108, #29306, #29550, #29619, and #31474 by @ClearlyClaire, @Gargron, @kernal053, @mjankowski, and @wheatear-dev)\
This replaces the “past interactions” recommendation algorithm with a “friends of friends” algorithm that suggests accounts followed by people you follow, and a “similar profiles” algorithm that suggests accounts with a profile similar to your most recent follows.\ This replaces the “past interactions” recommendation algorithm with a “friends of friends” algorithm that suggests accounts followed by people you follow, and a “similar profiles” algorithm that suggests accounts with a profile similar to your most recent follows.\
In addition, the implementation has been significantly reworked, and all follow recommendations are now dismissable.\ In addition, the implementation has been significantly reworked, and all follow recommendations are now dismissable.\

View File

@ -12,7 +12,7 @@ ARG BUILDPLATFORM=${BUILDPLATFORM}
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.3.x"] # Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.3.x"]
# renovate: datasource=docker depName=docker.io/ruby # renovate: datasource=docker depName=docker.io/ruby
ARG RUBY_VERSION="3.3.4" ARG RUBY_VERSION="3.3.5"
# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] # # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
# renovate: datasource=node-version depName=node # renovate: datasource=node-version depName=node
ARG NODE_MAJOR_VERSION="20" ARG NODE_MAJOR_VERSION="20"

13
Gemfile
View File

@ -99,10 +99,10 @@ gem 'json-ld'
gem 'json-ld-preloaded', '~> 3.2' gem 'json-ld-preloaded', '~> 3.2'
gem 'rdf-normalize', '~> 0.5' gem 'rdf-normalize', '~> 0.5'
gem 'opentelemetry-api', '~> 1.3.0' gem 'opentelemetry-api', '~> 1.4.0'
group :opentelemetry do group :opentelemetry do
gem 'opentelemetry-exporter-otlp', '~> 0.28.0', require: false gem 'opentelemetry-exporter-otlp', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-active_job', '~> 0.7.1', require: false gem 'opentelemetry-instrumentation-active_job', '~> 0.7.1', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.20.1', require: false gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.20.1', require: false
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.21.2', require: false gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.21.2', require: false
@ -126,9 +126,6 @@ group :test do
# Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab # Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab
gem 'rspec-github', '~> 2.4', require: false gem 'rspec-github', '~> 2.4', require: false
# RSpec progress bar formatter
gem 'fuubar', '~> 2.5'
# RSpec helpers for email specs # RSpec helpers for email specs
gem 'email_spec' gem 'email_spec'
@ -149,11 +146,13 @@ group :test do
gem 'rails-controller-testing', '~> 1.0' gem 'rails-controller-testing', '~> 1.0'
# Validate schemas in specs # Validate schemas in specs
gem 'json-schema', '~> 4.0' gem 'json-schema', '~> 5.0'
# Test harness fo rack components # Test harness fo rack components
gem 'rack-test', '~> 2.1' gem 'rack-test', '~> 2.1'
gem 'shoulda-matchers'
# Coverage formatter for RSpec test if DISABLE_SIMPLECOV is false # Coverage formatter for RSpec test if DISABLE_SIMPLECOV is false
gem 'simplecov', '~> 0.22', require: false gem 'simplecov', '~> 0.22', require: false
gem 'simplecov-lcov', '~> 0.8', require: false gem 'simplecov-lcov', '~> 0.8', require: false
@ -210,7 +209,7 @@ group :development, :test do
gem 'test-prof' gem 'test-prof'
# RSpec runner for rails # RSpec runner for rails
gem 'rspec-rails', '~> 6.0' gem 'rspec-rails', '~> 7.0'
end end
group :production do group :production do

View File

@ -10,35 +10,35 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.1.3.4) actioncable (7.1.4)
actionpack (= 7.1.3.4) actionpack (= 7.1.4)
activesupport (= 7.1.3.4) activesupport (= 7.1.4)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (7.1.3.4) actionmailbox (7.1.4)
actionpack (= 7.1.3.4) actionpack (= 7.1.4)
activejob (= 7.1.3.4) activejob (= 7.1.4)
activerecord (= 7.1.3.4) activerecord (= 7.1.4)
activestorage (= 7.1.3.4) activestorage (= 7.1.4)
activesupport (= 7.1.3.4) activesupport (= 7.1.4)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.1.3.4) actionmailer (7.1.4)
actionpack (= 7.1.3.4) actionpack (= 7.1.4)
actionview (= 7.1.3.4) actionview (= 7.1.4)
activejob (= 7.1.3.4) activejob (= 7.1.4)
activesupport (= 7.1.3.4) activesupport (= 7.1.4)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (7.1.3.4) actionpack (7.1.4)
actionview (= 7.1.3.4) actionview (= 7.1.4)
activesupport (= 7.1.3.4) activesupport (= 7.1.4)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc racc
rack (>= 2.2.4) rack (>= 2.2.4)
@ -46,15 +46,15 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
actiontext (7.1.3.4) actiontext (7.1.4)
actionpack (= 7.1.3.4) actionpack (= 7.1.4)
activerecord (= 7.1.3.4) activerecord (= 7.1.4)
activestorage (= 7.1.3.4) activestorage (= 7.1.4)
activesupport (= 7.1.3.4) activesupport (= 7.1.4)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.1.3.4) actionview (7.1.4)
activesupport (= 7.1.3.4) activesupport (= 7.1.4)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
@ -64,22 +64,22 @@ GEM
activemodel (>= 4.1) activemodel (>= 4.1)
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (7.1.3.4) activejob (7.1.4)
activesupport (= 7.1.3.4) activesupport (= 7.1.4)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.1.3.4) activemodel (7.1.4)
activesupport (= 7.1.3.4) activesupport (= 7.1.4)
activerecord (7.1.3.4) activerecord (7.1.4)
activemodel (= 7.1.3.4) activemodel (= 7.1.4)
activesupport (= 7.1.3.4) activesupport (= 7.1.4)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (7.1.3.4) activestorage (7.1.4)
actionpack (= 7.1.3.4) actionpack (= 7.1.4)
activejob (= 7.1.3.4) activejob (= 7.1.4)
activerecord (= 7.1.3.4) activerecord (= 7.1.4)
activesupport (= 7.1.3.4) activesupport (= 7.1.4)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.1.3.4) activesupport (7.1.4)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
@ -100,17 +100,17 @@ GEM
attr_required (1.0.2) attr_required (1.0.2)
awrence (1.2.1) awrence (1.2.1)
aws-eventstream (1.3.0) aws-eventstream (1.3.0)
aws-partitions (1.966.0) aws-partitions (1.974.0)
aws-sdk-core (3.201.5) aws-sdk-core (3.205.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.88.0) aws-sdk-kms (1.91.0)
aws-sdk-core (~> 3, >= 3.201.0) aws-sdk-core (~> 3, >= 3.205.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.159.0) aws-sdk-s3 (1.162.0)
aws-sdk-core (~> 3, >= 3.201.0) aws-sdk-core (~> 3, >= 3.205.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.9.1) aws-sigv4 (1.9.1)
@ -137,14 +137,14 @@ GEM
blurhash (0.1.7) blurhash (0.1.7)
bootsnap (1.18.4) bootsnap (1.18.4)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (6.1.2) brakeman (6.2.1)
racc racc
browser (5.3.1) browser (5.3.1)
brpoplpush-redis_script (0.1.3) brpoplpush-redis_script (0.1.3)
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
redis (>= 1.0, < 6) redis (>= 1.0, < 6)
builder (3.3.0) builder (3.3.0)
bundler-audit (0.9.1) bundler-audit (0.9.2)
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (~> 1.0) thor (~> 1.0)
capybara (3.40.0) capybara (3.40.0)
@ -164,20 +164,22 @@ GEM
activesupport (>= 5.2) activesupport (>= 5.2)
elasticsearch (>= 7.14.0, < 8) elasticsearch (>= 7.14.0, < 8)
elasticsearch-dsl elasticsearch-dsl
childprocess (5.1.0)
logger (~> 1.5)
chunky_png (1.4.0) chunky_png (1.4.0)
climate_control (1.2.0) climate_control (1.2.0)
cocoon (1.2.15) cocoon (1.2.15)
color_diff (0.1) color_diff (0.1)
concurrent-ruby (1.3.4) concurrent-ruby (1.3.4)
connection_pool (2.4.1) connection_pool (2.4.1)
cose (1.3.0) cose (1.3.1)
cbor (~> 0.5.9) cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0) openssl-signature_algorithm (~> 1.0)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
rexml rexml
crass (1.0.6) crass (1.0.6)
css_parser (1.17.1) css_parser (1.19.0)
addressable addressable
csv (3.3.0) csv (3.3.0)
database_cleaner-active_record (2.2.0) database_cleaner-active_record (2.2.0)
@ -206,20 +208,21 @@ GEM
diff-lcs (1.5.1) diff-lcs (1.5.1)
discard (1.3.0) discard (1.3.0)
activerecord (>= 4.2, < 8) activerecord (>= 4.2, < 8)
docile (1.4.0) docile (1.4.1)
domain_name (0.6.20240107) domain_name (0.6.20240107)
doorkeeper (5.7.1) doorkeeper (5.7.1)
railties (>= 5) railties (>= 5)
dotenv (3.1.2) dotenv (3.1.2)
drb (2.2.1) drb (2.2.1)
ed25519 (1.3.0) ed25519 (1.3.0)
elasticsearch (7.17.10) elasticsearch (7.17.11)
elasticsearch-api (= 7.17.10) elasticsearch-api (= 7.17.11)
elasticsearch-transport (= 7.17.10) elasticsearch-transport (= 7.17.11)
elasticsearch-api (7.17.10) elasticsearch-api (7.17.11)
multi_json multi_json
elasticsearch-dsl (0.1.10) elasticsearch-dsl (0.1.10)
elasticsearch-transport (7.17.10) elasticsearch-transport (7.17.11)
base64
faraday (>= 1, < 3) faraday (>= 1, < 3)
multi_json multi_json
email_spec (2.3.0) email_spec (2.3.0)
@ -251,7 +254,7 @@ GEM
faraday-httpclient (1.0.1) faraday-httpclient (1.0.1)
faraday-multipart (1.0.4) faraday-multipart (1.0.4)
multipart-post (~> 2) multipart-post (~> 2)
faraday-net_http (1.0.1) faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0) faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0) faraday-patron (1.0.0)
faraday-rack (1.0.0) faraday-rack (1.0.0)
@ -264,10 +267,11 @@ GEM
ffi-compiler (1.3.2) ffi-compiler (1.3.2)
ffi (>= 1.15.5) ffi (>= 1.15.5)
rake rake
flatware (2.3.2) flatware (2.3.3)
drb
thor (< 2.0) thor (< 2.0)
flatware-rspec (2.3.2) flatware-rspec (2.3.3)
flatware (= 2.3.2) flatware (= 2.3.3)
rspec (>= 3.6) rspec (>= 3.6)
fog-core (2.5.0) fog-core (2.5.0)
builder builder
@ -284,14 +288,11 @@ GEM
fugit (1.11.1) fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11) et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4) raabro (~> 1.4)
fuubar (2.5.1)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
google-protobuf (3.25.4) google-protobuf (3.25.4)
googleapis-common-protos-types (1.14.0) googleapis-common-protos-types (1.15.0)
google-protobuf (~> 3.18) google-protobuf (>= 3.18, < 5.a)
haml (6.3.0) haml (6.3.0)
temple (>= 0.8.2) temple (>= 0.8.2)
thor thor
@ -307,11 +308,12 @@ GEM
rainbow rainbow
rubocop (>= 1.0) rubocop (>= 1.0)
sysexits (~> 1.1) sysexits (~> 1.1)
hashdiff (1.1.0) hashdiff (1.1.1)
hashie (5.0.0) hashie (5.0.0)
hcaptcha (7.1.0) hcaptcha (7.1.0)
json json
highline (3.0.1) highline (3.1.1)
reline
hiredis (0.6.3) hiredis (0.6.3)
hkdf (0.3.0) hkdf (0.3.0)
htmlentities (4.3.4) htmlentities (4.3.4)
@ -342,7 +344,7 @@ GEM
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1) terminal-table (>= 1.5.1)
idn-ruby (0.1.5) idn-ruby (0.1.5)
inline_svg (1.9.0) inline_svg (1.10.0)
activesupport (>= 3.0) activesupport (>= 3.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
io-console (0.7.2) io-console (0.7.2)
@ -368,8 +370,8 @@ GEM
json-ld-preloaded (3.3.0) json-ld-preloaded (3.3.0)
json-ld (~> 3.3) json-ld (~> 3.3)
rdf (~> 3.3) rdf (~> 3.3)
json-schema (4.3.1) json-schema (5.0.0)
addressable (>= 2.8) addressable (~> 2.8)
jsonapi-renderer (0.2.2) jsonapi-renderer (0.2.2)
jwt (2.7.1) jwt (2.7.1)
kaminari (1.2.2) kaminari (1.2.2)
@ -391,8 +393,9 @@ GEM
mime-types mime-types
terrapin (>= 0.6.0, < 2.0) terrapin (>= 0.6.0, < 2.0)
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.3)
launchy (2.5.2) launchy (3.0.1)
addressable (~> 2.8) addressable (~> 2.8)
childprocess (~> 5.0)
letter_opener (1.10.0) letter_opener (1.10.0)
launchy (>= 2.2, < 4) launchy (>= 2.2, < 4)
letter_opener_web (3.0.0) letter_opener_web (3.0.0)
@ -429,19 +432,19 @@ GEM
memory_profiler (1.0.2) memory_profiler (1.0.2)
mime-types (3.5.2) mime-types (3.5.2)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2024.0702) mime-types-data (3.2024.0820)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.7) mini_portile2 (2.8.7)
minitest (5.24.1) minitest (5.25.1)
msgpack (1.7.2) msgpack (1.7.2)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.4.0) multipart-post (2.4.1)
mutex_m (0.2.0) mutex_m (0.2.0)
net-http (0.4.1) net-http (0.4.1)
uri uri
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.4.14) net-imap (0.4.15)
date date
net-protocol net-protocol
net-ldap (0.19.0) net-ldap (0.19.0)
@ -455,7 +458,7 @@ GEM
nokogiri (1.16.7) nokogiri (1.16.7)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
oj (3.16.5) oj (3.16.6)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
omniauth (2.1.2) omniauth (2.1.2)
@ -466,7 +469,7 @@ GEM
addressable (~> 2.8) addressable (~> 2.8)
nokogiri (~> 1.12) nokogiri (~> 1.12)
omniauth (~> 2.1) omniauth (~> 2.1)
omniauth-rails_csrf_protection (1.0.1) omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2) actionpack (>= 4.2)
omniauth (~> 2.0) omniauth (~> 2.0)
omniauth-saml (2.1.0) omniauth-saml (2.1.0)
@ -489,10 +492,10 @@ GEM
openssl (3.2.0) openssl (3.2.0)
openssl-signature_algorithm (1.3.0) openssl-signature_algorithm (1.3.0)
openssl (> 2.0) openssl (> 2.0)
opentelemetry-api (1.3.0) opentelemetry-api (1.4.0)
opentelemetry-common (0.20.1) opentelemetry-common (0.21.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.28.1) opentelemetry-exporter-otlp (0.29.0)
google-protobuf (>= 3.18) google-protobuf (>= 3.18)
googleapis-common-protos-types (~> 1.3) googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1) opentelemetry-api (~> 1.1)
@ -580,25 +583,25 @@ GEM
orm_adapter (0.5.0) orm_adapter (0.5.0)
ostruct (0.6.0) ostruct (0.6.0)
ox (2.14.18) ox (2.14.18)
parallel (1.25.1) parallel (1.26.3)
parser (3.3.4.0) parser (3.3.5.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
parslet (2.0.0) parslet (2.0.0)
pastel (0.8.0) pastel (0.8.0)
tty-color (~> 0.5) tty-color (~> 0.5)
pg (1.5.7) pg (1.5.8)
pghero (3.6.0) pghero (3.6.0)
activerecord (>= 6.1) activerecord (>= 6.1)
premailer (1.23.0) premailer (1.27.0)
addressable addressable
css_parser (>= 1.12.0) css_parser (>= 1.19.0)
htmlentities (>= 4.0.0) htmlentities (>= 4.0.0)
premailer-rails (1.12.0) premailer-rails (1.12.0)
actionmailer (>= 3) actionmailer (>= 3)
net-smtp net-smtp
premailer (~> 1.7, >= 1.7.9) premailer (~> 1.7, >= 1.7.9)
propshaft (0.9.1) propshaft (1.0.0)
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
@ -608,7 +611,7 @@ GEM
public_suffix (6.0.1) public_suffix (6.0.1)
puma (6.4.2) puma (6.4.2)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.2) pundit (2.4.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
@ -635,20 +638,20 @@ GEM
rackup (1.0.0) rackup (1.0.0)
rack (< 3) rack (< 3)
webrick webrick
rails (7.1.3.4) rails (7.1.4)
actioncable (= 7.1.3.4) actioncable (= 7.1.4)
actionmailbox (= 7.1.3.4) actionmailbox (= 7.1.4)
actionmailer (= 7.1.3.4) actionmailer (= 7.1.4)
actionpack (= 7.1.3.4) actionpack (= 7.1.4)
actiontext (= 7.1.3.4) actiontext (= 7.1.4)
actionview (= 7.1.3.4) actionview (= 7.1.4)
activejob (= 7.1.3.4) activejob (= 7.1.4)
activemodel (= 7.1.3.4) activemodel (= 7.1.4)
activerecord (= 7.1.3.4) activerecord (= 7.1.4)
activestorage (= 7.1.3.4) activestorage (= 7.1.4)
activesupport (= 7.1.3.4) activesupport (= 7.1.4)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.1.3.4) railties (= 7.1.4)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1)
@ -663,9 +666,9 @@ GEM
rails-i18n (7.0.9) rails-i18n (7.0.9)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8) railties (>= 6.0.0, < 8)
railties (7.1.3.4) railties (7.1.4)
actionpack (= 7.1.3.4) actionpack (= 7.1.4)
activesupport (= 7.1.3.4) activesupport (= 7.1.4)
irb irb
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@ -688,17 +691,16 @@ GEM
redlock (1.3.2) redlock (1.3.2)
redis (>= 3.0.0, < 6.0) redis (>= 3.0.0, < 6.0)
regexp_parser (2.9.2) regexp_parser (2.9.2)
reline (0.5.9) reline (0.5.10)
io-console (~> 0.5) io-console (~> 0.5)
request_store (1.6.0) request_store (1.6.0)
rack (>= 1.4) rack (>= 1.4)
responders (3.1.1) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
rexml (3.3.4) rexml (3.3.7)
strscan
rotp (6.3.0) rotp (6.3.0)
rouge (4.2.1) rouge (4.3.0)
rpam2 (4.0.2) rpam2 (4.0.2)
rqrcode (2.2.0) rqrcode (2.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
@ -708,9 +710,9 @@ GEM
rspec-core (~> 3.13.0) rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0) rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0) rspec-mocks (~> 3.13.0)
rspec-core (3.13.0) rspec-core (3.13.1)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.1) rspec-expectations (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-github (2.4.0) rspec-github (2.4.0)
@ -718,10 +720,10 @@ GEM
rspec-mocks (3.13.1) rspec-mocks (3.13.1)
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-rails (6.1.4) rspec-rails (7.0.1)
actionpack (>= 6.1) actionpack (>= 7.0)
activesupport (>= 6.1) activesupport (>= 7.0)
railties (>= 6.1) railties (>= 7.0)
rspec-core (~> 3.13) rspec-core (~> 3.13)
rspec-expectations (~> 3.13) rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13) rspec-mocks (~> 3.13)
@ -732,18 +734,17 @@ GEM
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8) sidekiq (>= 5, < 8)
rspec-support (3.13.1) rspec-support (3.13.1)
rubocop (1.65.1) rubocop (1.66.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.4, < 3.0) regexp_parser (>= 2.4, < 3.0)
rexml (>= 3.2.5, < 4.0) rubocop-ast (>= 1.32.2, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.3) rubocop-ast (1.32.3)
parser (>= 3.3.1.0) parser (>= 3.3.1.0)
rubocop-capybara (2.21.0) rubocop-capybara (2.21.0)
rubocop (~> 1.41) rubocop (~> 1.41)
@ -780,13 +781,15 @@ GEM
scenic (1.8.0) scenic (1.8.0)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
selenium-webdriver (4.23.0) selenium-webdriver (4.24.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)
rubyzip (>= 1.2.2, < 3.0) rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0) websocket (~> 1.0)
semantic_range (3.0.0) semantic_range (3.0.0)
shoulda-matchers (6.4.0)
activesupport (>= 5.2.0)
sidekiq (6.5.12) sidekiq (6.5.12)
connection_pool (>= 2.2.5, < 3) connection_pool (>= 2.2.5, < 3)
rack (~> 2.0) rack (~> 2.0)
@ -821,7 +824,6 @@ GEM
stringio (3.1.1) stringio (3.1.1)
strong_migrations (2.0.0) strong_migrations (2.0.0)
activerecord (>= 6.1) activerecord (>= 6.1)
strscan (3.1.0)
swd (1.3.0) swd (1.3.0)
activesupport (>= 3) activesupport (>= 3)
attr_required (>= 0.0.5) attr_required (>= 0.0.5)
@ -832,11 +834,11 @@ GEM
unicode-display_width (>= 1.1.1, < 3) unicode-display_width (>= 1.1.1, < 3)
terrapin (1.0.1) terrapin (1.0.1)
climate_control climate_control
test-prof (1.4.0) test-prof (1.4.2)
thor (1.3.1) thor (1.3.2)
tilt (2.3.0) tilt (2.4.0)
timeout (0.4.1) timeout (0.4.1)
tpm-key_attestation (0.12.0) tpm-key_attestation (0.12.1)
bindata (~> 2.4) bindata (~> 2.4)
openssl (> 2.0) openssl (> 2.0)
openssl-signature_algorithm (~> 1.0) openssl-signature_algorithm (~> 1.0)
@ -855,13 +857,13 @@ GEM
unf (~> 0.1.0) unf (~> 0.1.0)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
tzinfo-data (1.2024.1) tzinfo-data (1.2024.2)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.9.1) unf_ext (0.0.9.1)
unicode-display_width (2.5.0) unicode-display_width (2.5.0)
uri (0.13.0) uri (0.13.1)
validate_email (0.1.6) validate_email (0.1.6)
activemodel (>= 3.0) activemodel (>= 3.0)
mail (>= 2.2.5) mail (>= 2.2.5)
@ -900,7 +902,7 @@ GEM
xorcist (1.1.3) xorcist (1.1.3)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.6.17) zeitwerk (2.6.18)
PLATFORMS PLATFORMS
ruby ruby
@ -943,7 +945,6 @@ DEPENDENCIES
flatware-rspec flatware-rspec
fog-core (<= 2.5.0) fog-core (<= 2.5.0)
fog-openstack (~> 1.0) fog-openstack (~> 1.0)
fuubar (~> 2.5)
haml-rails (~> 2.0) haml-rails (~> 2.0)
haml_lint haml_lint
hcaptcha (~> 7.1) hcaptcha (~> 7.1)
@ -959,7 +960,7 @@ DEPENDENCIES
irb (~> 1.8) irb (~> 1.8)
json-ld json-ld
json-ld-preloaded (~> 3.2) json-ld-preloaded (~> 3.2)
json-schema (~> 4.0) json-schema (~> 5.0)
kaminari (~> 1.2) kaminari (~> 1.2)
kt-paperclip (~> 7.2) kt-paperclip (~> 7.2)
letter_opener (~> 1.8) letter_opener (~> 1.8)
@ -980,8 +981,8 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 1.0) omniauth-rails_csrf_protection (~> 1.0)
omniauth-saml (~> 2.0) omniauth-saml (~> 2.0)
omniauth_openid_connect (~> 0.6.1) omniauth_openid_connect (~> 0.6.1)
opentelemetry-api (~> 1.3.0) opentelemetry-api (~> 1.4.0)
opentelemetry-exporter-otlp (~> 0.28.0) opentelemetry-exporter-otlp (~> 0.29.0)
opentelemetry-instrumentation-active_job (~> 0.7.1) opentelemetry-instrumentation-active_job (~> 0.7.1)
opentelemetry-instrumentation-active_model_serializers (~> 0.20.1) opentelemetry-instrumentation-active_model_serializers (~> 0.20.1)
opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2) opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2)
@ -1018,7 +1019,7 @@ DEPENDENCIES
redis-namespace (~> 1.10) redis-namespace (~> 1.10)
rqrcode (~> 2.2) rqrcode (~> 2.2)
rspec-github (~> 2.4) rspec-github (~> 2.4)
rspec-rails (~> 6.0) rspec-rails (~> 7.0)
rspec-sidekiq (~> 5.0) rspec-sidekiq (~> 5.0)
rubocop rubocop
rubocop-capybara rubocop-capybara
@ -1033,6 +1034,7 @@ DEPENDENCIES
sanitize (~> 6.0) sanitize (~> 6.0)
scenic (~> 1.7) scenic (~> 1.7)
selenium-webdriver selenium-webdriver
shoulda-matchers
sidekiq (~> 6.5) sidekiq (~> 6.5)
sidekiq-bulk (~> 0.2.0) sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 5.0) sidekiq-scheduler (~> 5.0)
@ -1056,7 +1058,7 @@ DEPENDENCIES
xorcist (~> 1.1) xorcist (~> 1.1)
RUBY VERSION RUBY VERSION
ruby 3.3.2p78 ruby 3.3.4p94
BUNDLED WITH BUNDLED WITH
2.5.11 2.5.18

View File

@ -11,4 +11,4 @@ worker: bundle exec sidekiq
# #
# and let the main app use the separate app: # and let the main app use the separate app:
# #
# heroku config:set STREAMING_API_BASE_URL=wss://<streaming-app>.herokuapp.com -a <main-app> # heroku config:set STREAMING_API_BASE_URL=wss://<streaming-app-random>.herokuapp.com -a <main-app>

View File

@ -90,9 +90,15 @@
} }
}, },
"buildpacks": [ "buildpacks": [
{
"url": "https://github.com/heroku/heroku-buildpack-activestorage-preview"
},
{ {
"url": "https://github.com/heroku/heroku-buildpack-apt" "url": "https://github.com/heroku/heroku-buildpack-apt"
}, },
{
"url": "heroku/nodejs"
},
{ {
"url": "heroku/ruby" "url": "heroku/ruby"
} }
@ -100,5 +106,6 @@
"scripts": { "scripts": {
"postdeploy": "bundle exec rails db:migrate && bundle exec rails db:seed" "postdeploy": "bundle exec rails db:migrate && bundle exec rails db:seed"
}, },
"addons": ["heroku-postgresql", "heroku-redis"] "addons": ["heroku-postgresql", "heroku-redis"],
"stack": "heroku-24"
} }

View File

@ -13,7 +13,7 @@ module Admin
redirect_to admin_account_path(@account_moderation_note.target_account_id), notice: I18n.t('admin.account_moderation_notes.created_msg') redirect_to admin_account_path(@account_moderation_note.target_account_id), notice: I18n.t('admin.account_moderation_notes.created_msg')
else else
@account = @account_moderation_note.target_account @account = @account_moderation_note.target_account
@moderation_notes = @account.targeted_moderation_notes.latest @moderation_notes = @account.targeted_moderation_notes.chronological.includes(:account)
@warnings = @account.strikes.custom.latest @warnings = @account.strikes.custom.latest
render 'admin/accounts/show' render 'admin/accounts/show'

View File

@ -33,7 +33,7 @@ module Admin
@deletion_request = @account.deletion_request @deletion_request = @account.deletion_request
@account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
@moderation_notes = @account.targeted_moderation_notes.latest @moderation_notes = @account.targeted_moderation_notes.chronological.includes(:account)
@warnings = @account.strikes.includes(:target_account, :account, :appeal).latest @warnings = @account.strikes.includes(:target_account, :account, :appeal).latest
@domain_block = DomainBlock.rule_for(@account.domain) @domain_block = DomainBlock.rule_for(@account.domain)
end end

View File

@ -7,17 +7,12 @@ module Admin
layout 'admin' layout 'admin'
before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
after_action :verify_authorized after_action :verify_authorized
private private
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers def set_cache_headers
response.cache_control.replace(private: true, no_store: true) response.cache_control.replace(private: true, no_store: true)
end end

View File

@ -7,12 +7,12 @@ module Admin
def index def index
authorize :dashboard, :index? authorize :dashboard, :index?
@pending_appeals_count = Appeal.pending.async_count
@pending_reports_count = Report.unresolved.async_count
@pending_tags_count = Tag.pending_review.async_count
@pending_users_count = User.pending.async_count
@system_checks = Admin::SystemCheck.perform(current_user) @system_checks = Admin::SystemCheck.perform(current_user)
@time_period = (29.days.ago.to_date...Time.now.utc.to_date) @time_period = (29.days.ago.to_date...Time.now.utc.to_date)
@pending_users_count = User.pending.count
@pending_reports_count = Report.unresolved.count
@pending_tags_count = Tag.pending_review.count
@pending_appeals_count = Appeal.pending.count
end end
end end
end end

View File

@ -21,7 +21,7 @@ module Admin
redirect_to after_create_redirect_path, notice: I18n.t('admin.report_notes.created_msg') redirect_to after_create_redirect_path, notice: I18n.t('admin.report_notes.created_msg')
else else
@report_notes = @report.notes.includes(:account).order(id: :desc) @report_notes = @report.notes.chronological.includes(:account)
@action_logs = @report.history.includes(:target) @action_logs = @report.history.includes(:target)
@form = Admin::StatusBatchAction.new @form = Admin::StatusBatchAction.new
@statuses = @report.statuses.with_includes @statuses = @report.statuses.with_includes

View File

@ -13,7 +13,7 @@ module Admin
authorize @report, :show? authorize @report, :show?
@report_note = @report.notes.new @report_note = @report.notes.new
@report_notes = @report.notes.includes(:account).order(id: :desc) @report_notes = @report.notes.chronological.includes(:account)
@action_logs = @report.history.includes(:target) @action_logs = @report.history.includes(:target)
@form = Admin::StatusBatchAction.new @form = Admin::StatusBatchAction.new
@statuses = @report.statuses.with_includes @statuses = @report.statuses.with_includes

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
class Api::V2Alpha::Notifications::AccountsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }
before_action :require_user!
before_action :set_notifications!
after_action :insert_pagination_headers, only: :index
def index
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
private
def load_accounts
@paginated_notifications.map(&:from_account)
end
def set_notifications!
@paginated_notifications = begin
current_account
.notifications
.without_suspended
.where(group_key: params[:notification_group_key])
.includes(from_account: [:account_stat, :user])
.paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params[:max_id],
params[:since_id]
)
end
end
def next_path
api_v2_alpha_notification_accounts_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
api_v2_alpha_notification_accounts_url pagination_params(min_id: pagination_since_id) unless @paginated_notifications.empty?
end
def pagination_collection
@paginated_notifications
end
def records_continue?
@paginated_notifications.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
end

View File

@ -13,7 +13,6 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
def index def index
with_read_replica do with_read_replica do
@notifications = load_notifications @notifications = load_notifications
@group_metadata = load_group_metadata
@grouped_notifications = load_grouped_notifications @grouped_notifications = load_grouped_notifications
@relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id) @relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
@presenter = GroupedNotificationsPresenter.new(@grouped_notifications, expand_accounts: expand_accounts_param) @presenter = GroupedNotificationsPresenter.new(@grouped_notifications, expand_accounts: expand_accounts_param)
@ -34,7 +33,7 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
'app.notification_grouping.expand_accounts_param' => expand_accounts_param 'app.notification_grouping.expand_accounts_param' => expand_accounts_param
) )
render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata, expand_accounts: expand_accounts_param render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param
end end
end end
@ -42,13 +41,13 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
limit = limit_param(DEFAULT_NOTIFICATIONS_COUNT_LIMIT, MAX_NOTIFICATIONS_COUNT_LIMIT) limit = limit_param(DEFAULT_NOTIFICATIONS_COUNT_LIMIT, MAX_NOTIFICATIONS_COUNT_LIMIT)
with_read_replica do with_read_replica do
render json: { count: browserable_account_notifications.paginate_groups_by_min_id(limit, min_id: notification_marker&.last_read_id).count } render json: { count: browserable_account_notifications.paginate_groups_by_min_id(limit, min_id: notification_marker&.last_read_id, grouped_types: params[:grouped_types]).count }
end end
end end
def show def show
@notification = current_account.notifications.without_suspended.find_by!(group_key: params[:id]) @notification = current_account.notifications.without_suspended.find_by!(group_key: params[:group_key])
presenter = GroupedNotificationsPresenter.new([NotificationGroup.from_notification(@notification)]) presenter = GroupedNotificationsPresenter.new(NotificationGroup.from_notifications([@notification]))
render json: presenter, serializer: REST::DedupNotificationGroupSerializer render json: presenter, serializer: REST::DedupNotificationGroupSerializer
end end
@ -58,7 +57,7 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
end end
def dismiss def dismiss
current_account.notifications.where(group_key: params[:id]).destroy_all current_account.notifications.where(group_key: params[:group_key]).destroy_all
render_empty render_empty
end end
@ -68,7 +67,7 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_notifications') do MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_notifications') do
notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id( notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id(
limit_param(DEFAULT_NOTIFICATIONS_LIMIT), limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
params_slice(:max_id, :since_id, :min_id) params.slice(:max_id, :since_id, :min_id, :grouped_types).permit(:max_id, :since_id, :min_id, grouped_types: [])
) )
Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses| Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses|
@ -77,22 +76,11 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
end end
end end
def load_group_metadata
return {} if @notifications.empty?
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_group_metadata') do
browserable_account_notifications
.where(group_key: @notifications.filter_map(&:group_key))
.where(id: (@notifications.last.id)..(@notifications.first.id))
.group(:group_key)
.pluck(:group_key, 'min(notifications.id) as min_id', 'max(notifications.id) as max_id', 'max(notifications.created_at) as latest_notification_at')
.to_h { |group_key, min_id, max_id, latest_notification_at| [group_key, { min_id: min_id, max_id: max_id, latest_notification_at: latest_notification_at }] }
end
end
def load_grouped_notifications def load_grouped_notifications
return [] if @notifications.empty?
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_grouped_notifications') do MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_grouped_notifications') do
@notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) } NotificationGroup.from_notifications(@notifications, pagination_range: (@notifications.last.id)..(@notifications.first.id), grouped_types: params[:grouped_types])
end end
end end
@ -125,11 +113,11 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
end end
def browserable_params def browserable_params
params.permit(:include_filtered, types: [], exclude_types: []) params.slice(:include_filtered, :types, :exclude_types, :grouped_types).permit(:include_filtered, types: [], exclude_types: [], grouped_types: [])
end end
def pagination_params(core_params) def pagination_params(core_params)
params.slice(:limit, :types, :exclude_types, :include_filtered).permit(:limit, :include_filtered, types: [], exclude_types: []).merge(core_params) params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types).permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: []).merge(core_params)
end end
def expand_accounts_param def expand_accounts_param

View File

@ -11,7 +11,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :configure_sign_up_params, only: [:create] before_action :configure_sign_up_params, only: [:create]
before_action :set_sessions, only: [:edit, :update] before_action :set_sessions, only: [:edit, :update]
before_action :set_strikes, only: [:edit, :update] before_action :set_strikes, only: [:edit, :update]
before_action :set_body_classes, only: [:new, :create, :edit, :update]
before_action :require_not_suspended!, only: [:update] before_action :require_not_suspended!, only: [:update]
before_action :set_cache_headers, only: [:edit, :update] before_action :set_cache_headers, only: [:edit, :update]
before_action :set_rules, only: :new before_action :set_rules, only: :new
@ -104,10 +103,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
private private
def set_body_classes
@body_classes = 'admin' if %w(edit update).include?(action_name)
end
def set_invite def set_invite
@invite = begin @invite = begin
invite = Invite.find_by(code: invite_code) if invite_code.present? invite = Invite.find_by(code: invite_code) if invite_code.present?

View File

@ -20,7 +20,7 @@ module AccountControllerConcern
webfinger_account_link, webfinger_account_link,
actor_url_link, actor_url_link,
] ]
) ).to_s
end end
def webfinger_account_link def webfinger_account_link

View File

@ -19,7 +19,7 @@ module Api::Pagination
links = [] links = []
links << [next_path, [%w(rel next)]] if next_path links << [next_path, [%w(rel next)]] if next_path
links << [prev_path, [%w(rel prev)]] if prev_path links << [prev_path, [%w(rel prev)]] if prev_path
response.headers['Link'] = LinkHeader.new(links) unless links.empty? response.headers['Link'] = LinkHeader.new(links).to_s unless links.empty?
end end
def require_valid_pagination_options! def require_valid_pagination_options!

View File

@ -8,6 +8,16 @@ module WebAppControllerConcern
before_action :redirect_unauthenticated_to_permalinks! before_action :redirect_unauthenticated_to_permalinks!
before_action :set_app_body_class before_action :set_app_body_class
content_security_policy do |p|
policy = ContentSecurityPolicy.new
if policy.sso_host.present?
p.form_action policy.sso_host
else
p.form_action :none
end
end
end end
def skip_csrf_meta_tags? def skip_csrf_meta_tags?

View File

@ -7,16 +7,11 @@ class Disputes::BaseController < ApplicationController
skip_before_action :require_functional! skip_before_action :require_functional!
before_action :set_body_classes
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_cache_headers before_action :set_cache_headers
private private
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers def set_cache_headers
response.cache_control.replace(private: true, no_store: true) response.cache_control.replace(private: true, no_store: true)
end end

View File

@ -6,7 +6,6 @@ class Filters::StatusesController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_filter before_action :set_filter
before_action :set_status_filters before_action :set_status_filters
before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
PER_PAGE = 20 PER_PAGE = 20
@ -42,10 +41,6 @@ class Filters::StatusesController < ApplicationController
'remove' if params[:remove] 'remove' if params[:remove]
end end
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers def set_cache_headers
response.cache_control.replace(private: true, no_store: true) response.cache_control.replace(private: true, no_store: true)
end end

View File

@ -5,7 +5,6 @@ class FiltersController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_filter, only: [:edit, :update, :destroy] before_action :set_filter, only: [:edit, :update, :destroy]
before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
def index def index
@ -52,10 +51,6 @@ class FiltersController < ApplicationController
params.require(:custom_filter).permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) params.require(:custom_filter).permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy])
end end
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers def set_cache_headers
response.cache_control.replace(private: true, no_store: true) response.cache_control.replace(private: true, no_store: true)
end end

View File

@ -6,7 +6,6 @@ class InvitesController < ApplicationController
layout 'admin' layout 'admin'
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
def index def index
@ -47,10 +46,6 @@ class InvitesController < ApplicationController
params.require(:invite).permit(:max_uses, :expires_in, :autofollow, :comment) params.require(:invite).permit(:max_uses, :expires_in, :autofollow, :comment)
end end
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers def set_cache_headers
response.cache_control.replace(private: true, no_store: true) response.cache_control.replace(private: true, no_store: true)
end end

View File

@ -19,9 +19,7 @@ class MediaController < ApplicationController
redirect_to @media_attachment.file.url(:original) redirect_to @media_attachment.file.url(:original)
end end
def player def player; end
@body_classes = 'player'
end
private private

View File

@ -6,7 +6,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
before_action :store_current_location before_action :store_current_location
before_action :authenticate_resource_owner! before_action :authenticate_resource_owner!
before_action :require_not_suspended!, only: :destroy before_action :require_not_suspended!, only: :destroy
before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
before_action :set_last_used_at_by_app, only: :index, unless: -> { request.format == :json } before_action :set_last_used_at_by_app, only: :index, unless: -> { request.format == :json }
@ -23,10 +22,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
private private
def set_body_classes
@body_classes = 'admin'
end
def store_current_location def store_current_location
store_location_for(:user, request.url) store_location_for(:user, request.url)
end end

View File

@ -4,7 +4,6 @@ class Redirect::BaseController < ApplicationController
vary_by 'Accept-Language' vary_by 'Accept-Language'
before_action :set_resource before_action :set_resource
before_action :set_app_body_class
def show def show
@redirect_path = ActivityPub::TagManager.instance.url_for(@resource) @redirect_path = ActivityPub::TagManager.instance.url_for(@resource)
@ -14,10 +13,6 @@ class Redirect::BaseController < ApplicationController
private private
def set_app_body_class
@body_classes = 'app-body'
end
def set_resource def set_resource
raise NotImplementedError raise NotImplementedError
end end

View File

@ -6,7 +6,6 @@ class RelationshipsController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_accounts, only: :show before_action :set_accounts, only: :show
before_action :set_relationships, only: :show before_action :set_relationships, only: :show
before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship? helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship?
@ -68,10 +67,6 @@ class RelationshipsController < ApplicationController
end end
end end
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers def set_cache_headers
response.cache_control.replace(private: true, no_store: true) response.cache_control.replace(private: true, no_store: true)
end end

View File

@ -4,15 +4,10 @@ class Settings::BaseController < ApplicationController
layout 'admin' layout 'admin'
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
private private
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers def set_cache_headers
response.cache_control.replace(private: true, no_store: true) response.cache_control.replace(private: true, no_store: true)
end end

View File

@ -2,14 +2,30 @@
class Settings::VerificationsController < Settings::BaseController class Settings::VerificationsController < Settings::BaseController
before_action :set_account before_action :set_account
before_action :set_verified_links
def show def show; end
@verified_links = @account.fields.select(&:verified?)
def update
if UpdateAccountService.new.call(@account, account_params)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
redirect_to settings_verification_path, notice: I18n.t('generic.changes_saved_msg')
else
render :show
end
end end
private private
def account_params
params.require(:account).permit(:attribution_domains_as_text)
end
def set_account def set_account
@account = current_account @account = current_account
end end
def set_verified_links
@verified_links = @account.fields.select(&:verified?)
end
end end

View File

@ -4,7 +4,6 @@ class SeveredRelationshipsController < ApplicationController
layout 'admin' layout 'admin'
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
before_action :set_event, only: [:following, :followers] before_action :set_event, only: [:following, :followers]
@ -51,10 +50,6 @@ class SeveredRelationshipsController < ApplicationController
account.local? ? account.local_username_and_domain : account.acct account.local? ? account.local_username_and_domain : account.acct
end end
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers def set_cache_headers
response.cache_control.replace(private: true, no_store: true) response.cache_control.replace(private: true, no_store: true)
end end

View File

@ -4,13 +4,6 @@ class SharesController < ApplicationController
layout 'modal' layout 'modal'
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_body_classes
def show; end def show; end
private
def set_body_classes
@body_classes = 'modal-layout compose-standalone'
end
end end

View File

@ -5,7 +5,6 @@ class StatusesCleanupController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_policy before_action :set_policy
before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
def show; end def show; end
@ -34,10 +33,6 @@ class StatusesCleanupController < ApplicationController
params.require(:account_statuses_cleanup_policy).permit(:enabled, :min_status_age, :keep_direct, :keep_pinned, :keep_polls, :keep_media, :keep_self_fav, :keep_self_bookmark, :min_favs, :min_reblogs) params.require(:account_statuses_cleanup_policy).permit(:enabled, :min_status_age, :keep_direct, :keep_pinned, :keep_polls, :keep_media, :keep_self_fav, :keep_self_bookmark, :min_favs, :min_reblogs)
end end
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers def set_cache_headers
response.cache_control.replace(private: true, no_store: true) response.cache_control.replace(private: true, no_store: true)
end end

View File

@ -11,7 +11,6 @@ class StatusesController < ApplicationController
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? } before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_status before_action :set_status
before_action :redirect_to_original, only: :show before_action :redirect_to_original, only: :show
before_action :set_body_classes, only: :embed
after_action :set_link_headers after_action :set_link_headers
@ -51,12 +50,10 @@ class StatusesController < ApplicationController
private private
def set_body_classes
@body_classes = 'with-modals'
end
def set_link_headers def set_link_headers
response.headers['Link'] = LinkHeader.new([[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]) response.headers['Link'] = LinkHeader.new(
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]
).to_s
end end
def set_status def set_status

View File

@ -19,14 +19,6 @@ module AccountsHelper
end end
end end
def account_action_button(account)
return if account.memorial? || account.moved?
link_to ActivityPub::TagManager.instance.url_for(account), class: 'button logo-button', target: '_new' do
safe_join([logo_as_symbol, t('accounts.follow')])
end
end
def account_formatted_stat(value) def account_formatted_stat(value)
number_to_human(value, precision: 3, strip_insignificant_zeros: true) number_to_human(value, precision: 3, strip_insignificant_zeros: true)
end end

View File

@ -5,7 +5,7 @@ module Admin::Trends::StatusesHelper
text = if status.local? text = if status.local?
status.text.split("\n").first status.text.split("\n").first
else else
Nokogiri::HTML(status.text).css('html > body > *').first&.text Nokogiri::HTML5(status.text).css('html > body > *').first&.text
end end
return '' if text.blank? return '' if text.blank?

View File

@ -86,7 +86,7 @@ module ApplicationHelper
def html_title def html_title
safe_join( safe_join(
[content_for(:page_title).to_s.chomp, title] [content_for(:page_title).to_s.chomp, title]
.select(&:present?), .compact_blank,
' - ' ' - '
) )
end end
@ -106,11 +106,16 @@ module ApplicationHelper
end end
def material_symbol(icon, attributes = {}) def material_symbol(icon, attributes = {})
inline_svg_tag( safe_join(
"400-24px/#{icon}.svg", [
class: ['icon', "material-#{icon}"].concat(attributes[:class].to_s.split), inline_svg_tag(
role: :img, "400-24px/#{icon}.svg",
data: attributes[:data] class: ['icon', "material-#{icon}"].concat(attributes[:class].to_s.split),
role: :img,
data: attributes[:data]
),
' ',
]
) )
end end
@ -154,6 +159,7 @@ module ApplicationHelper
def body_classes def body_classes
output = body_class_string.split output = body_class_string.split
output << content_for(:body_classes)
output << "theme-#{current_theme.parameterize}" output << "theme-#{current_theme.parameterize}"
output << 'system-font' if current_account&.user&.setting_system_font_ui output << 'system-font' if current_account&.user&.setting_system_font_ui
output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion') output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion')

View File

@ -41,6 +41,7 @@ module ContextHelper
'cipherText' => 'toot:cipherText', 'cipherText' => 'toot:cipherText',
}, },
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
}.freeze }.freeze
def full_context def full_context

View File

@ -200,14 +200,6 @@ module JsonLdHelper
nil nil
end end
def merge_context(context, new_context)
if context.is_a?(Array)
context << new_context
else
[context, new_context]
end
end
def response_successful?(response) def response_successful?(response)
(200...300).cover?(response.code) (200...300).cover?(response.code)
end end

View File

@ -238,9 +238,7 @@ module LanguagesHelper
# Helper for self.sorted_locale_keys # Helper for self.sorted_locale_keys
private_class_method def self.locale_name_for_sorting(locale) private_class_method def self.locale_name_for_sorting(locale)
if locale.blank? || locale == 'und' if (supported_locale = SUPPORTED_LOCALES[locale.to_sym])
'000'
elsif (supported_locale = SUPPORTED_LOCALES[locale.to_sym])
ASCIIFolding.new.fold(supported_locale[1]).downcase ASCIIFolding.new.fold(supported_locale[1]).downcase
elsif (regional_locale = REGIONAL_LOCALE_NAMES[locale.to_sym]) elsif (regional_locale = REGIONAL_LOCALE_NAMES[locale.to_sym])
ASCIIFolding.new.fold(regional_locale).downcase ASCIIFolding.new.fold(regional_locale).downcase

View File

@ -57,26 +57,6 @@ module MediaComponentHelper
end end
end end
def render_card_component(status, **options)
component_params = {
sensitive: sensitive_viewer?(status, current_account),
card: serialize_status_card(status).as_json,
}.merge(**options)
react_component :card, component_params
end
def render_poll_component(status, **options)
component_params = {
disabled: true,
poll: serialize_status_poll(status).as_json,
}.merge(**options)
react_component :poll, component_params do
render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: prefers_autoplay? }
end
end
private private
def serialize_media_attachment(attachment) def serialize_media_attachment(attachment)
@ -86,22 +66,6 @@ module MediaComponentHelper
) )
end end
def serialize_status_card(status)
ActiveModelSerializers::SerializableResource.new(
status.preview_card,
serializer: REST::PreviewCardSerializer
)
end
def serialize_status_poll(status)
ActiveModelSerializers::SerializableResource.new(
status.preloadable_poll,
serializer: REST::PollSerializer,
scope: current_user,
scope_name: :current_user
)
end
def sensitive_viewer?(status, account) def sensitive_viewer?(status, account)
if !account.nil? && account.id == status.account_id if !account.nil? && account.id == status.account_id
status.sensitive status.sensitive

View File

@ -4,6 +4,13 @@ module StatusesHelper
EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_CONTROLLER = 'statuses'
EMBEDDED_ACTION = 'embed' EMBEDDED_ACTION = 'embed'
VISIBLITY_ICONS = {
public: 'globe',
unlisted: 'lock_open',
private: 'lock',
direct: 'alternate_email',
}.freeze
def nothing_here(extra_classes = '') def nothing_here(extra_classes = '')
content_tag(:div, class: "nothing-here #{extra_classes}") do content_tag(:div, class: "nothing-here #{extra_classes}") do
t('accounts.nothing_here') t('accounts.nothing_here')
@ -57,17 +64,8 @@ module StatusesHelper
embedded_view? ? '_blank' : nil embedded_view? ? '_blank' : nil
end end
def fa_visibility_icon(status) def visibility_icon(status)
case status.visibility VISIBLITY_ICONS[status.visibility.to_sym]
when 'public'
material_symbol 'globe'
when 'unlisted'
material_symbol 'lock_open'
when 'private'
material_symbol 'lock'
when 'direct'
material_symbol 'alternate_email'
end
end end
def embedded_view? def embedded_view?

View File

@ -0,0 +1,74 @@
import './public-path';
import { createRoot } from 'react-dom/client';
import { afterInitialRender } from 'mastodon/../hooks/useRenderSignal';
import { start } from '../mastodon/common';
import { Status } from '../mastodon/features/standalone/status';
import { loadPolyfills } from '../mastodon/polyfills';
import ready from '../mastodon/ready';
start();
function loaded() {
const mountNode = document.getElementById('mastodon-status');
if (mountNode) {
const attr = mountNode.getAttribute('data-props');
if (!attr) return;
const props = JSON.parse(attr) as { id: string; locale: string };
const root = createRoot(mountNode);
root.render(<Status {...props} />);
}
}
function main() {
ready(loaded).catch((error: unknown) => {
console.error(error);
});
}
loadPolyfills()
.then(main)
.catch((error: unknown) => {
console.error(error);
});
interface SetHeightMessage {
type: 'setHeight';
id: string;
height: number;
}
function isSetHeightMessage(data: unknown): data is SetHeightMessage {
if (
data &&
typeof data === 'object' &&
'type' in data &&
data.type === 'setHeight'
)
return true;
else return false;
}
window.addEventListener('message', (e) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases
if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return;
const data = e.data;
// We use a timeout to allow for the React page to render before calculating the height
afterInitialRender(() => {
window.parent.postMessage(
{
type: 'setHeight',
id: data.id,
height: document.getElementsByTagName('html')[0]?.scrollHeight,
},
'*',
);
});
});

View File

@ -37,43 +37,6 @@ const messages = defineMessages({
}, },
}); });
interface SetHeightMessage {
type: 'setHeight';
id: string;
height: number;
}
function isSetHeightMessage(data: unknown): data is SetHeightMessage {
if (
data &&
typeof data === 'object' &&
'type' in data &&
data.type === 'setHeight'
)
return true;
else return false;
}
window.addEventListener('message', (e) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases
if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return;
const data = e.data;
ready(() => {
window.parent.postMessage(
{
type: 'setHeight',
id: data.id,
height: document.getElementsByTagName('html')[0]?.scrollHeight,
},
'*',
);
}).catch((e: unknown) => {
console.error('Error in setHeightMessage postMessage', e);
});
});
function loaded() { function loaded() {
const { messages: localeData } = getLocale(); const { messages: localeData } = getLocale();

View File

@ -0,0 +1,32 @@
// This hook allows a component to signal that it's done rendering in a way that
// can be used by e.g. our embed code to determine correct iframe height
let renderSignalReceived = false;
type Callback = () => void;
let onInitialRender: Callback;
export const afterInitialRender = (callback: Callback) => {
if (renderSignalReceived) {
callback();
} else {
onInitialRender = callback;
}
};
export const useRenderSignal = () => {
return () => {
if (renderSignalReceived) {
return;
}
renderSignalReceived = true;
if (typeof onInitialRender !== 'undefined') {
window.requestAnimationFrame(() => {
onInitialRender();
});
}
};
};

View File

@ -6,5 +6,4 @@ export const submitAccountNote = createDataLoadingThunk(
({ accountId, note }: { accountId: string; note: string }) => ({ accountId, note }: { accountId: string; note: string }) =>
apiSubmitAccountNote(accountId, note), apiSubmitAccountNote(accountId, note),
(relationship) => ({ relationship }), (relationship) => ({ relationship }),
{ skipLoading: true },
); );

View File

@ -1,12 +0,0 @@
import { saveSettings } from './settings';
export const LANGUAGE_USE = 'LANGUAGE_USE';
export const useLanguage = language => dispatch => {
dispatch({
type: LANGUAGE_USE,
language,
});
dispatch(saveSettings());
};

View File

@ -2,6 +2,7 @@ import { debounce } from 'lodash';
import type { MarkerJSON } from 'mastodon/api_types/markers'; import type { MarkerJSON } from 'mastodon/api_types/markers';
import { getAccessToken } from 'mastodon/initial_state'; import { getAccessToken } from 'mastodon/initial_state';
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
import type { AppDispatch, RootState } from 'mastodon/store'; import type { AppDispatch, RootState } from 'mastodon/store';
import { createAppAsyncThunk } from 'mastodon/store/typed_functions'; import { createAppAsyncThunk } from 'mastodon/store/typed_functions';
@ -64,7 +65,7 @@ export const synchronouslySubmitMarkers = createAppAsyncThunk(
client.setRequestHeader('Content-Type', 'application/json'); client.setRequestHeader('Content-Type', 'application/json');
client.setRequestHeader('Authorization', `Bearer ${accessToken}`); client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
client.send(JSON.stringify(params)); client.send(JSON.stringify(params));
} catch (e) { } catch {
// Do not make the BeforeUnload handler error out // Do not make the BeforeUnload handler error out
} }
}, },
@ -75,13 +76,8 @@ interface MarkerParam {
} }
function getLastNotificationId(state: RootState): string | undefined { function getLastNotificationId(state: RootState): string | undefined {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const enableBeta = state.settings.getIn(
['notifications', 'groupingBeta'],
false,
) as boolean;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return // eslint-disable-next-line @typescript-eslint/no-unsafe-return
return enableBeta return selectUseGroupedNotifications(state)
? state.notificationGroups.lastReadId ? state.notificationGroups.lastReadId
: // @ts-expect-error state.notifications is not yet typed : // @ts-expect-error state.notifications is not yet typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-call // eslint-disable-next-line @typescript-eslint/no-unsafe-call

View File

@ -18,7 +18,7 @@ import {
selectSettingsNotificationsQuickFilterActive, selectSettingsNotificationsQuickFilterActive,
selectSettingsNotificationsShows, selectSettingsNotificationsShows,
} from 'mastodon/selectors/settings'; } from 'mastodon/selectors/settings';
import type { AppDispatch } from 'mastodon/store'; import type { AppDispatch, RootState } from 'mastodon/store';
import { import {
createAppAsyncThunk, createAppAsyncThunk,
createDataLoadingThunk, createDataLoadingThunk,
@ -32,6 +32,14 @@ function excludeAllTypesExcept(filter: string) {
return allNotificationTypes.filter((item) => item !== filter); return allNotificationTypes.filter((item) => item !== filter);
} }
function getExcludedTypes(state: RootState) {
const activeFilter = selectSettingsNotificationsQuickFilterActive(state);
return activeFilter === 'all'
? selectSettingsNotificationsExcludedTypes(state)
: excludeAllTypesExcept(activeFilter);
}
function dispatchAssociatedRecords( function dispatchAssociatedRecords(
dispatch: AppDispatch, dispatch: AppDispatch,
notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[], notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[],
@ -62,17 +70,8 @@ function dispatchAssociatedRecords(
export const fetchNotifications = createDataLoadingThunk( export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch', 'notificationGroups/fetch',
async (_params, { getState }) => { async (_params, { getState }) =>
const activeFilter = apiFetchNotifications({ exclude_types: getExcludedTypes(getState()) }),
selectSettingsNotificationsQuickFilterActive(getState());
return apiFetchNotifications({
exclude_types:
activeFilter === 'all'
? selectSettingsNotificationsExcludedTypes(getState())
: excludeAllTypesExcept(activeFilter),
});
},
({ notifications, accounts, statuses }, { dispatch }) => { ({ notifications, accounts, statuses }, { dispatch }) => {
dispatch(importFetchedAccounts(accounts)); dispatch(importFetchedAccounts(accounts));
dispatch(importFetchedStatuses(statuses)); dispatch(importFetchedStatuses(statuses));
@ -92,9 +91,11 @@ export const fetchNotifications = createDataLoadingThunk(
export const fetchNotificationsGap = createDataLoadingThunk( export const fetchNotificationsGap = createDataLoadingThunk(
'notificationGroups/fetchGap', 'notificationGroups/fetchGap',
async (params: { gap: NotificationGap }) => async (params: { gap: NotificationGap }, { getState }) =>
apiFetchNotifications({ max_id: params.gap.maxId }), apiFetchNotifications({
max_id: params.gap.maxId,
exclude_types: getExcludedTypes(getState()),
}),
({ notifications, accounts, statuses }, { dispatch }) => { ({ notifications, accounts, statuses }, { dispatch }) => {
dispatch(importFetchedAccounts(accounts)); dispatch(importFetchedAccounts(accounts));
dispatch(importFetchedStatuses(statuses)); dispatch(importFetchedStatuses(statuses));
@ -109,6 +110,7 @@ export const pollRecentNotifications = createDataLoadingThunk(
async (_params, { getState }) => { async (_params, { getState }) => {
return apiFetchNotifications({ return apiFetchNotifications({
max_id: undefined, max_id: undefined,
exclude_types: getExcludedTypes(getState()),
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones // In slow mode, we don't want to include notifications that duplicate the already-displayed ones
since_id: usePendingItems since_id: usePendingItems
? getState().notificationGroups.groups.find( ? getState().notificationGroups.groups.find(
@ -183,7 +185,6 @@ export const setNotificationsFilter = createAppAsyncThunk(
path: ['notifications', 'quickFilter', 'active'], path: ['notifications', 'quickFilter', 'active'],
value: filterType, value: filterType,
}); });
// dispatch(expandNotifications({ forceLoad: true }));
void dispatch(fetchNotifications()); void dispatch(fetchNotifications());
dispatch(saveSettings()); dispatch(saveSettings());
}, },

View File

@ -1,3 +1,4 @@
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
import { createAppAsyncThunk } from 'mastodon/store'; import { createAppAsyncThunk } from 'mastodon/store';
import { fetchNotifications } from './notification_groups'; import { fetchNotifications } from './notification_groups';
@ -6,13 +7,8 @@ import { expandNotifications } from './notifications';
export const initializeNotifications = createAppAsyncThunk( export const initializeNotifications = createAppAsyncThunk(
'notifications/initialize', 'notifications/initialize',
(_, { dispatch, getState }) => { (_, { dispatch, getState }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access if (selectUseGroupedNotifications(getState()))
const enableBeta = getState().settings.getIn( void dispatch(fetchNotifications());
['notifications', 'groupingBeta'],
false,
) as boolean;
if (enableBeta) void dispatch(fetchNotifications());
else void dispatch(expandNotifications({})); else void dispatch(expandNotifications({}));
}, },
); );

View File

@ -49,11 +49,13 @@ export function fetchStatusRequest(id, skipLoading) {
}; };
} }
export function fetchStatus(id, forceFetch = false) { export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
return (dispatch, getState) => { return (dispatch, getState) => {
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null; const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
dispatch(fetchContext(id)); if (alsoFetchContext) {
dispatch(fetchContext(id));
}
if (skipLoading) { if (skipLoading) {
return; return;

View File

@ -1,5 +1,7 @@
// @ts-check // @ts-check
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
import { getLocale } from '../locales'; import { getLocale } from '../locales';
import { connectStream } from '../stream'; import { connectStream } from '../stream';
@ -103,7 +105,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
const notificationJSON = JSON.parse(data.payload); const notificationJSON = JSON.parse(data.payload);
dispatch(updateNotifications(notificationJSON, messages, locale)); dispatch(updateNotifications(notificationJSON, messages, locale));
// TODO: remove this once the groups feature replaces the previous one // TODO: remove this once the groups feature replaces the previous one
if(getState().settings.getIn(['notifications', 'groupingBeta'], false)) { if(selectUseGroupedNotifications(getState())) {
dispatch(processNewNotificationForGroups(notificationJSON)); dispatch(processNewNotificationForGroups(notificationJSON));
} }
break; break;
@ -112,7 +114,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
const state = getState(); const state = getState();
if (state.notifications.top || !state.notifications.mounted) if (state.notifications.top || !state.notifications.mounted)
dispatch(expandNotifications({ forceLoad: true, maxId: undefined })); dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
if(state.settings.getIn(['notifications', 'groupingBeta'], false)) { if (selectUseGroupedNotifications(state)) {
dispatch(refreshStaleNotificationGroups()); dispatch(refreshStaleNotificationGroups());
} }
break; break;
@ -145,11 +147,11 @@ async function refreshHomeTimelineAndNotification(dispatch, getState) {
await dispatch(expandHomeTimeline({ maxId: undefined })); await dispatch(expandHomeTimeline({ maxId: undefined }));
// TODO: remove this once the groups feature replaces the previous one // TODO: remove this once the groups feature replaces the previous one
if(getState().settings.getIn(['notifications', 'groupingBeta'], false)) { if(selectUseGroupedNotifications(getState())) {
// TODO: polling for merged notifications // TODO: polling for merged notifications
try { try {
await dispatch(pollRecentGroupNotifications()); await dispatch(pollRecentGroupNotifications());
} catch (error) { } catch {
// TODO // TODO
} }
} else { } else {

View File

@ -5,7 +5,7 @@ export function start() {
try { try {
Rails.start(); Rails.start();
} catch (e) { } catch {
// If called twice // If called twice
} }
} }

View File

@ -0,0 +1,90 @@
import { useRef, useState, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
import { useTimeout } from 'mastodon/../hooks/useTimeout';
import { Icon } from 'mastodon/components/icon';
export const CopyPasteText: React.FC<{ value: string }> = ({ value }) => {
const inputRef = useRef<HTMLTextAreaElement>(null);
const [copied, setCopied] = useState(false);
const [focused, setFocused] = useState(false);
const [setAnimationTimeout] = useTimeout();
const handleInputClick = useCallback(() => {
setCopied(false);
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
inputRef.current.setSelectionRange(0, value.length);
}
}, [setCopied, value]);
const handleButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
void navigator.clipboard.writeText(value);
inputRef.current?.blur();
setCopied(true);
setAnimationTimeout(() => {
setCopied(false);
}, 700);
},
[setCopied, setAnimationTimeout, value],
);
const handleKeyUp = useCallback(
(e: React.KeyboardEvent) => {
if (e.key !== ' ') return;
void navigator.clipboard.writeText(value);
setCopied(true);
setAnimationTimeout(() => {
setCopied(false);
}, 700);
},
[setCopied, setAnimationTimeout, value],
);
const handleFocus = useCallback(() => {
setFocused(true);
}, [setFocused]);
const handleBlur = useCallback(() => {
setFocused(false);
}, [setFocused]);
return (
<div
className={classNames('copy-paste-text', { copied, focused })}
tabIndex={0}
role='button'
onClick={handleInputClick}
onKeyUp={handleKeyUp}
>
<textarea
readOnly
value={value}
ref={inputRef}
onClick={handleInputClick}
onFocus={handleFocus}
onBlur={handleBlur}
/>
<button className='button' onClick={handleButtonClick}>
<Icon id='copy' icon={ContentCopyIcon} />{' '}
{copied ? (
<FormattedMessage id='copypaste.copied' defaultMessage='Copied' />
) : (
<FormattedMessage
id='copypaste.copy_to_clipboard'
defaultMessage='Copy to clipboard'
/>
)}
</button>
</div>
);
};

View File

@ -60,8 +60,8 @@ export default class ErrorBoundary extends PureComponent {
try { try {
textarea.select(); textarea.select();
document.execCommand('copy'); document.execCommand('copy');
} catch (e) { } catch {
// do nothing
} finally { } finally {
document.body.removeChild(textarea); document.body.removeChild(textarea);
} }

View File

@ -7,6 +7,13 @@ export const WordmarkLogo: React.FC = () => (
</svg> </svg>
); );
export const IconLogo: React.FC = () => (
<svg viewBox='0 0 79 79' className='logo logo--icon' role='img'>
<title>Mastodon</title>
<use xlinkHref='#logo-symbol-icon' />
</svg>
);
export const SymbolLogo: React.FC = () => ( export const SymbolLogo: React.FC = () => (
<img src={logo} alt='Mastodon' className='logo logo--icon' /> <img src={logo} alt='Mastodon' className='logo logo--icon' />
); );

View File

@ -1,7 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
@ -10,17 +10,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import { Blurhash } from 'mastodon/components/blurhash'; import { Blurhash } from 'mastodon/components/blurhash';
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state'; import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
import { IconButton } from './icon_button';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: '{number, plural, one {Hide image} other {Hide images}}' },
});
class Item extends PureComponent { class Item extends PureComponent {
static propTypes = { static propTypes = {
@ -215,7 +208,6 @@ class MediaGallery extends PureComponent {
size: PropTypes.object, size: PropTypes.object,
height: PropTypes.number.isRequired, height: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired, onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
defaultWidth: PropTypes.number, defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func, cacheWidth: PropTypes.func,
visible: PropTypes.bool, visible: PropTypes.bool,
@ -291,7 +283,7 @@ class MediaGallery extends PureComponent {
} }
render () { render () {
const { media, lang, intl, sensitive, defaultWidth, autoplay } = this.props; const { media, lang, sensitive, defaultWidth, autoplay } = this.props;
const { visible } = this.state; const { visible } = this.state;
const width = this.state.width || defaultWidth; const width = this.state.width || defaultWidth;
@ -323,9 +315,7 @@ class MediaGallery extends PureComponent {
</span> </span>
</button> </button>
); );
} else if (visible) { } else if (!visible) {
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' iconComponent={VisibilityOffIcon} overlay onClick={this.handleOpen} ariaHidden />;
} else {
spoilerButton = ( spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'> <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'> <span className='spoiler-button__overlay__label'>
@ -337,16 +327,24 @@ class MediaGallery extends PureComponent {
} }
return ( return (
<div className='media-gallery' style={style} ref={this.handleRef}> <div className={`media-gallery media-gallery--layout-${size}`} style={style} ref={this.handleRef}>
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}> {(!visible || uncached) && (
{spoilerButton} <div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
</div> {spoilerButton}
</div>
)}
{children} {children}
{(visible && !uncached) && (
<div className='media-gallery__actions'>
<button className='media-gallery__actions__pill' onClick={this.handleOpen}><FormattedMessage id='media_gallery.hide' defaultMessage='Hide' /></button>
</div>
)}
</div> </div>
); );
} }
} }
export default injectIntl(MediaGallery); export default MediaGallery;

View File

@ -2,14 +2,12 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { IconLogo } from 'mastodon/components/logo';
import { AuthorLink } from 'mastodon/features/explore/components/author_link'; import { AuthorLink } from 'mastodon/features/explore/components/author_link';
export const MoreFromAuthor = ({ accountId }) => ( export const MoreFromAuthor = ({ accountId }) => (
<div className='more-from-author'> <div className='more-from-author'>
<svg viewBox='0 0 79 79' className='logo logo--icon' role='img'> <IconLogo />
<use xlinkHref='#logo-symbol-icon' />
</svg>
<FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <AuthorLink accountId={accountId} /> }} /> <FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <AuthorLink accountId={accountId} /> }} />
</div> </div>
); );

View File

@ -12,7 +12,6 @@ import { HotKeys } from 'react-hotkeys';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react'; import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import { ContentWarning } from 'mastodon/components/content_warning'; import { ContentWarning } from 'mastodon/components/content_warning';
import { FilterWarning } from 'mastodon/components/filter_warning'; import { FilterWarning } from 'mastodon/components/filter_warning';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
@ -34,6 +33,7 @@ import { getHashtagBarForStatus } from './hashtag_bar';
import { RelativeTimestamp } from './relative_timestamp'; import { RelativeTimestamp } from './relative_timestamp';
import StatusActionBar from './status_action_bar'; import StatusActionBar from './status_action_bar';
import StatusContent from './status_content'; import StatusContent from './status_content';
import { StatusThreadLabel } from './status_thread_label';
import { VisibilityIcon } from './visibility_icon'; import { VisibilityIcon } from './visibility_icon';
const domParser = new DOMParser(); const domParser = new DOMParser();
@ -418,7 +418,7 @@ class Status extends ImmutablePureComponent {
if (featured) { if (featured) {
prepend = ( prepend = (
<div className='status__prepend'> <div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='thumb-tack' icon={PushPinIcon} className='status__prepend-icon' /></div> <div className='status__prepend__icon'><Icon id='thumb-tack' icon={PushPinIcon} /></div>
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' /> <FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
</div> </div>
); );
@ -427,7 +427,7 @@ class Status extends ImmutablePureComponent {
prepend = ( prepend = (
<div className='status__prepend'> <div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='retweet' icon={RepeatIcon} className='status__prepend-icon' /></div> <div className='status__prepend__icon'><Icon id='retweet' icon={RepeatIcon} /></div>
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} /> <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
</div> </div>
); );
@ -439,18 +439,13 @@ class Status extends ImmutablePureComponent {
} else if (status.get('visibility') === 'direct') { } else if (status.get('visibility') === 'direct') {
prepend = ( prepend = (
<div className='status__prepend'> <div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='at' icon={AlternateEmailIcon} className='status__prepend-icon' /></div> <div className='status__prepend__icon'><Icon id='at' icon={AlternateEmailIcon} /></div>
<FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' /> <FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
</div> </div>
); );
} else if (showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id'])) { } else if (showThread && status.get('in_reply_to_id')) {
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
prepend = ( prepend = (
<div className='status__prepend'> <StatusThreadLabel accountId={status.getIn(['account', 'id'])} inReplyToAccountId={status.get('in_reply_to_account_id')} />
<div className='status__prepend-icon-wrapper'><Icon id='reply' icon={ReplyIcon} className='status__prepend-icon' /></div>
<FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
</div>
); );
} }

View File

@ -55,7 +55,7 @@ const messages = defineMessages({
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
embed: { id: 'status.embed', defaultMessage: 'Embed' }, embed: { id: 'status.embed', defaultMessage: 'Get embed code' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' }, admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },

View File

@ -0,0 +1,50 @@
import { FormattedMessage } from 'react-intl';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import { Icon } from 'mastodon/components/icon';
import { DisplayedName } from 'mastodon/features/notifications_v2/components/displayed_name';
import { useAppSelector } from 'mastodon/store';
export const StatusThreadLabel: React.FC<{
accountId: string;
inReplyToAccountId: string;
}> = ({ accountId, inReplyToAccountId }) => {
const inReplyToAccount = useAppSelector((state) =>
state.accounts.get(inReplyToAccountId),
);
let label;
if (accountId === inReplyToAccountId) {
label = (
<FormattedMessage
id='status.continued_thread'
defaultMessage='Continued thread'
/>
);
} else if (inReplyToAccount) {
label = (
<FormattedMessage
id='status.replied_to'
defaultMessage='Replied to {name}'
values={{ name: <DisplayedName accountIds={[inReplyToAccountId]} /> }}
/>
);
} else {
label = (
<FormattedMessage
id='status.replied_in_thread'
defaultMessage='Replied in thread'
/>
);
}
return (
<div className='status__prepend'>
<div className='status__prepend__icon'>
<Icon id='reply' icon={ReplyIcon} />
</div>
{label}
</div>
);
};

View File

@ -6,7 +6,6 @@ import {
unmuteAccount, unmuteAccount,
unblockAccount, unblockAccount,
} from '../actions/accounts'; } from '../actions/accounts';
import { showAlertForError } from '../actions/alerts';
import { initBlockModal } from '../actions/blocks'; import { initBlockModal } from '../actions/blocks';
import { import {
replyCompose, replyCompose,
@ -100,10 +99,7 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
onEmbed (status) { onEmbed (status) {
dispatch(openModal({ dispatch(openModal({
modalType: 'EMBED', modalType: 'EMBED',
modalProps: { modalProps: { id: status.get('id') },
id: status.get('id'),
onError: error => dispatch(showAlertForError(error)),
},
})); }));
}, },

View File

@ -240,7 +240,6 @@ class LanguageDropdown extends PureComponent {
frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string), frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string),
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
onChange: PropTypes.func, onChange: PropTypes.func,
onClose: PropTypes.func,
}; };
state = { state = {
@ -257,14 +256,11 @@ class LanguageDropdown extends PureComponent {
}; };
handleClose = () => { handleClose = () => {
const { value, onClose } = this.props;
if (this.state.open && this.activeElement) { if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true }); this.activeElement.focus({ preventScroll: true });
} }
this.setState({ open: false }); this.setState({ open: false });
onClose(value);
}; };
handleChange = value => { handleChange = value => {

View File

@ -4,7 +4,6 @@ import { connect } from 'react-redux';
import { changeComposeLanguage } from 'mastodon/actions/compose'; import { changeComposeLanguage } from 'mastodon/actions/compose';
import { useLanguage } from 'mastodon/actions/languages';
import LanguageDropdown from '../components/language_dropdown'; import LanguageDropdown from '../components/language_dropdown';
@ -28,11 +27,6 @@ const mapDispatchToProps = dispatch => ({
dispatch(changeComposeLanguage(value)); dispatch(changeComposeLanguage(value));
}, },
onClose (value) {
// eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook
dispatch(useLanguage(value));
},
}); });
export default connect(mapStateToProps, mapDispatchToProps)(LanguageDropdown); export default connect(mapStateToProps, mapDispatchToProps)(LanguageDropdown);

View File

@ -170,7 +170,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers}>
<div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}> <div className={classNames('conversation focusable muted', { unread })} tabIndex={0}>
<div className='conversation__avatar' onClick={handleClick} role='presentation'> <div className='conversation__avatar' onClick={handleClick} role='presentation'>
<AvatarComposite accounts={accounts} size={48} /> <AvatarComposite accounts={accounts} size={48} />
</div> </div>

View File

@ -12,9 +12,11 @@ import { connect } from 'react-redux';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react'; import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react'; import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react'; import PublicIcon from '@/material-icons/400-24px/public.svg?react';
@ -25,6 +27,7 @@ import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header'; import ColumnHeader from 'mastodon/components/column_header';
import LinkFooter from 'mastodon/features/ui/components/link_footer'; import LinkFooter from 'mastodon/features/ui/components/link_footer';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
import { me, showTrends } from '../../initial_state'; import { me, showTrends } from '../../initial_state';
import { NavigationBar } from '../compose/components/navigation_bar'; import { NavigationBar } from '../compose/components/navigation_bar';
@ -43,6 +46,8 @@ const messages = defineMessages({
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
administration: { id: 'navigation_bar.administration', defaultMessage: 'Administration' },
moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
@ -99,7 +104,7 @@ class GettingStarted extends ImmutablePureComponent {
render () { render () {
const { intl, myAccount, multiColumn, unreadFollowRequests } = this.props; const { intl, myAccount, multiColumn, unreadFollowRequests } = this.props;
const { signedIn } = this.props.identity; const { signedIn, permissions } = this.props.identity;
const navItems = []; const navItems = [];
@ -136,6 +141,13 @@ class GettingStarted extends ImmutablePureComponent {
<ColumnSubheading key='header-settings' text={intl.formatMessage(messages.settings_subheading)} />, <ColumnSubheading key='header-settings' text={intl.formatMessage(messages.settings_subheading)} />,
<ColumnLink key='preferences' icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />, <ColumnLink key='preferences' icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />,
); );
if (canManageReports(permissions)) {
navItems.push(<ColumnLink key='moderation' href='/admin/reports' icon='flag' iconComponent={ModerationIcon} text={intl.formatMessage(messages.moderation)} />);
}
if (canViewAdminDashboard(permissions)) {
navItems.push(<ColumnLink key='administration' href='/admin/dashboard' icon='tachometer' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.administration)} />);
}
} }
return ( return (

View File

@ -131,7 +131,7 @@ class LoginForm extends React.PureComponent {
try { try {
new URL(url); new URL(url);
return true; return true;
} catch(_) { } catch {
return false; return false;
} }
}; };

View File

@ -6,6 +6,7 @@ import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { forceGroupedNotifications } from 'mastodon/initial_state';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'mastodon/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'mastodon/permissions';
import ClearColumnButton from './clear_column_button'; import ClearColumnButton from './clear_column_button';
@ -67,15 +68,17 @@ class ColumnSettings extends PureComponent {
<PolicyControls /> <PolicyControls />
<section role='group' aria-labelledby='notifications-beta'> {!forceGroupedNotifications && (
<h3 id='notifications-beta'> <section role='group' aria-labelledby='notifications-beta'>
<FormattedMessage id='notifications.column_settings.beta.category' defaultMessage='Experimental features' /> <h3 id='notifications-beta'>
</h3> <FormattedMessage id='notifications.column_settings.beta.category' defaultMessage='Experimental features' />
</h3>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['groupingBeta']} onChange={onChange} label={groupingShowStr} /> <SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['groupingBeta']} onChange={onChange} label={groupingShowStr} />
</div> </div>
</section> </section>
)}
<section role='group' aria-labelledby='notifications-unread-markers'> <section role='group' aria-labelledby='notifications-unread-markers'>
<h3 id='notifications-unread-markers'> <h3 id='notifications-unread-markers'>

View File

@ -31,11 +31,11 @@ import { RelationshipsSeveranceEvent } from './relationships_severance_event';
import Report from './report'; import Report from './report';
const messages = defineMessages({ const messages = defineMessages({
favourite: { id: 'notification.favourite', defaultMessage: '{name} favorited your status' }, favourite: { id: 'notification.favourite', defaultMessage: '{name} favorited your post' },
follow: { id: 'notification.follow', defaultMessage: '{name} followed you' }, follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' }, ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
poll: { id: 'notification.poll', defaultMessage: 'A poll you voted in has ended' }, poll: { id: 'notification.poll', defaultMessage: 'A poll you voted in has ended' },
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your post' },
status: { id: 'notification.status', defaultMessage: '{name} just posted' }, status: { id: 'notification.status', defaultMessage: '{name} just posted' },
update: { id: 'notification.update', defaultMessage: '{name} edited a post' }, update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' }, adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
@ -201,7 +201,7 @@ class Notification extends ImmutablePureComponent {
<Icon id='star' icon={StarIcon} className='star-icon' /> <Icon id='star' icon={StarIcon} className='star-icon' />
<span title={notification.get('created_at')}> <span title={notification.get('created_at')}>
<FormattedMessage id='notification.favourite' defaultMessage='{name} favorited your status' values={{ name: link }} /> <FormattedMessage id='notification.favourite' defaultMessage='{name} favorited your post' values={{ name: link }} />
</span> </span>
</div> </div>
@ -231,7 +231,7 @@ class Notification extends ImmutablePureComponent {
<Icon id='retweet' icon={RepeatIcon} /> <Icon id='retweet' icon={RepeatIcon} />
<span title={notification.get('created_at')}> <span title={notification.get('created_at')}>
<FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your post' values={{ name: link }} />
</span> </span>
</div> </div>

View File

@ -42,19 +42,11 @@ export const NotificationAdminReport: React.FC<{
if (!account || !targetAccount) return null; if (!account || !targetAccount) return null;
const domain = account.acct.split('@')[1];
const values = { const values = {
name: ( name: <bdi>{domain ?? `@${account.acct}`}</bdi>,
<bdi target: <bdi>@{targetAccount.acct}</bdi>,
dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }}
/>
),
target: (
<bdi
dangerouslySetInnerHTML={{
__html: targetAccount.get('display_name_html'),
}}
/>
),
category: intl.formatMessage(messages[report.category]), category: intl.formatMessage(messages[report.category]),
count: report.status_ids.length, count: report.status_ids.length,
}; };

View File

@ -14,7 +14,7 @@ const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => {
return ( return (
<FormattedMessage <FormattedMessage
id='notification.favourite' id='notification.favourite'
defaultMessage='{name} favorited your status' defaultMessage='{name} favorited your post'
values={{ name: displayedName }} values={{ name: displayedName }}
/> />
); );

View File

@ -9,7 +9,7 @@ import { navigateToStatus } from 'mastodon/actions/statuses';
import type { IconProp } from 'mastodon/components/icon'; import type { IconProp } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { AvatarGroup } from './avatar_group'; import { AvatarGroup } from './avatar_group';
import { DisplayedName } from './displayed_name'; import { DisplayedName } from './displayed_name';
@ -60,6 +60,10 @@ export const NotificationGroupWithStatus: React.FC<{
[labelRenderer, accountIds, count, labelSeeMoreHref], [labelRenderer, accountIds, count, labelSeeMoreHref],
); );
const isPrivateMention = useAppSelector(
(state) => state.statuses.getIn([statusId, 'visibility']) === 'direct',
);
const handlers = useMemo( const handlers = useMemo(
() => ({ () => ({
open: () => { open: () => {
@ -79,7 +83,10 @@ export const NotificationGroupWithStatus: React.FC<{
role='button' role='button'
className={classNames( className={classNames(
`notification-group focusable notification-group--${type}`, `notification-group focusable notification-group--${type}`,
{ 'notification-group--unread': unread }, {
'notification-group--unread': unread,
'notification-group--direct': isPrivateMention,
},
)} )}
tabIndex={0} tabIndex={0}
> >

View File

@ -1,5 +1,7 @@
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { isEqual } from 'lodash';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
import { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
@ -47,7 +49,7 @@ export const NotificationMention: React.FC<{
status.get('visibility') === 'direct', status.get('visibility') === 'direct',
status.get('in_reply_to_account_id') === me, status.get('in_reply_to_account_id') === me,
] as const; ] as const;
}); }, isEqual);
let labelRenderer = mentionLabelRenderer; let labelRenderer = mentionLabelRenderer;

View File

@ -14,7 +14,7 @@ const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => {
return ( return (
<FormattedMessage <FormattedMessage
id='notification.reblog' id='notification.reblog'
defaultMessage='{name} boosted your status' defaultMessage='{name} boosted your post'
values={{ name: displayedName }} values={{ name: displayedName }}
/> />
); );

View File

@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { isEqual } from 'lodash';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react'; import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
@ -62,7 +63,7 @@ export const Notifications: React.FC<{
multiColumn?: boolean; multiColumn?: boolean;
}> = ({ columnId, multiColumn }) => { }> = ({ columnId, multiColumn }) => {
const intl = useIntl(); const intl = useIntl();
const notifications = useAppSelector(selectNotificationGroups); const notifications = useAppSelector(selectNotificationGroups, isEqual);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isLoading = useAppSelector((s) => s.notificationGroups.isLoading); const isLoading = useAppSelector((s) => s.notificationGroups.isLoading);
const hasMore = notifications.at(-1)?.type === 'gap'; const hasMore = notifications.at(-1)?.type === 'gap';

View File

@ -1,9 +1,10 @@
import Notifications from 'mastodon/features/notifications'; import Notifications from 'mastodon/features/notifications';
import Notifications_v2 from 'mastodon/features/notifications_v2'; import Notifications_v2 from 'mastodon/features/notifications_v2';
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
import { useAppSelector } from 'mastodon/store'; import { useAppSelector } from 'mastodon/store';
export const NotificationsWrapper = (props) => { export const NotificationsWrapper = (props) => {
const optedInGroupedNotifications = useAppSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false)); const optedInGroupedNotifications = useAppSelector(selectUseGroupedNotifications);
return ( return (
optedInGroupedNotifications ? <Notifications_v2 {...props} /> : <Notifications {...props} /> optedInGroupedNotifications ? <Notifications_v2 {...props} /> : <Notifications {...props} />

View File

@ -10,8 +10,8 @@ import { Link } from 'react-router-dom';
import SwipeableViews from 'react-swipeable-views'; import SwipeableViews from 'react-swipeable-views';
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react'; import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
import { ColumnBackButton } from 'mastodon/components/column_back_button'; import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { CopyPasteText } from 'mastodon/components/copy_paste_text';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { me, domain } from 'mastodon/initial_state'; import { me, domain } from 'mastodon/initial_state';
import { useAppSelector } from 'mastodon/store'; import { useAppSelector } from 'mastodon/store';
@ -20,67 +20,6 @@ const messages = defineMessages({
shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' }, shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' },
}); });
class CopyPasteText extends PureComponent {
static propTypes = {
value: PropTypes.string,
};
state = {
copied: false,
focused: false,
};
setRef = c => {
this.input = c;
};
handleInputClick = () => {
this.setState({ copied: false });
this.input.focus();
this.input.select();
this.input.setSelectionRange(0, this.props.value.length);
};
handleButtonClick = e => {
e.stopPropagation();
const { value } = this.props;
navigator.clipboard.writeText(value);
this.input.blur();
this.setState({ copied: true });
this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
};
handleFocus = () => {
this.setState({ focused: true });
};
handleBlur = () => {
this.setState({ focused: false });
};
componentWillUnmount () {
if (this.timeout) clearTimeout(this.timeout);
}
render () {
const { value } = this.props;
const { copied, focused } = this.state;
return (
<div className={classNames('copy-paste-text', { copied, focused })} tabIndex='0' role='button' onClick={this.handleInputClick}>
<textarea readOnly value={value} ref={this.setRef} onClick={this.handleInputClick} onFocus={this.handleFocus} onBlur={this.handleBlur} />
<button className='button' onClick={this.handleButtonClick}>
<Icon id='copy' icon={ContentCopyIcon} /> {copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : <FormattedMessage id='copypaste.copy_to_clipboard' defaultMessage='Copy to clipboard' />}
</button>
</div>
);
}
}
class TipCarousel extends PureComponent { class TipCarousel extends PureComponent {
static propTypes = { static propTypes = {

View File

@ -0,0 +1,87 @@
/* eslint-disable @typescript-eslint/no-unsafe-return,
@typescript-eslint/no-explicit-any,
@typescript-eslint/no-unsafe-assignment */
import { useEffect, useCallback } from 'react';
import { Provider } from 'react-redux';
import { useRenderSignal } from 'mastodon/../hooks/useRenderSignal';
import { fetchStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
import { hydrateStore } from 'mastodon/actions/store';
import { Router } from 'mastodon/components/router';
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
import initialState from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales';
import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors';
import { store, useAppSelector, useAppDispatch } from 'mastodon/store';
const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any;
const getPictureInPicture = makeGetPictureInPicture() as unknown as (
arg0: any,
arg1: any,
) => any;
const Embed: React.FC<{ id: string }> = ({ id }) => {
const status = useAppSelector((state) => getStatus(state, { id }));
const pictureInPicture = useAppSelector((state) =>
getPictureInPicture(state, { id }),
);
const domain = useAppSelector((state) => state.meta.get('domain'));
const dispatch = useAppDispatch();
const dispatchRenderSignal = useRenderSignal();
useEffect(() => {
dispatch(fetchStatus(id, false, false));
}, [dispatch, id]);
const handleToggleHidden = useCallback(() => {
dispatch(toggleStatusSpoilers(id));
}, [dispatch, id]);
// This allows us to calculate the correct page height for embeds
if (status) {
dispatchRenderSignal();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const permalink = status?.get('url') as string;
return (
<div className='embed'>
<DetailedStatus
status={status}
domain={domain}
pictureInPicture={pictureInPicture}
onToggleHidden={handleToggleHidden}
withLogo
/>
<a
className='embed__overlay'
href={permalink}
target='_blank'
rel='noreferrer noopener'
aria-label=''
/>
</div>
);
};
export const Status: React.FC<{ id: string }> = ({ id }) => {
useEffect(() => {
if (initialState) {
store.dispatch(hydrateStore(initialState));
}
}, []);
return (
<IntlProvider>
<Provider store={store}>
<Router>
<Embed id={id} />
</Router>
</Provider>
</IntlProvider>
);
};

View File

@ -49,7 +49,7 @@ const messages = defineMessages({
share: { id: 'status.share', defaultMessage: 'Share' }, share: { id: 'status.share', defaultMessage: 'Share' },
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
embed: { id: 'status.embed', defaultMessage: 'Embed' }, embed: { id: 'status.embed', defaultMessage: 'Get embed code' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' }, admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },

View File

@ -1,329 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedDate, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link, withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { AnimatedNumber } from 'mastodon/components/animated_number';
import { ContentWarning } from 'mastodon/components/content_warning';
import EditedTimestamp from 'mastodon/components/edited_timestamp';
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
import { Icon } from 'mastodon/components/icon';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import MediaGallery from '../../../components/media_gallery';
import StatusContent from '../../../components/status_content';
import Audio from '../../audio';
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import Video from '../../video';
import Card from './card';
class DetailedStatus extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired,
onTranslate: PropTypes.func.isRequired,
onUndoStatusTranslation: PropTypes.func.isRequired,
measureHeight: PropTypes.bool,
onHeightChange: PropTypes.func,
domain: PropTypes.string.isRequired,
compact: PropTypes.bool,
showMedia: PropTypes.bool,
pictureInPicture: ImmutablePropTypes.contains({
inUse: PropTypes.bool,
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func,
...WithRouterPropTypes,
};
state = {
height: null,
};
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.props.history) {
e.preventDefault();
this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
}
e.stopPropagation();
};
handleOpenVideo = (options) => {
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options);
};
handleExpandedToggle = () => {
this.props.onToggleHidden(this.props.status);
};
_measureHeight (heightJustChanged) {
if (this.props.measureHeight && this.node) {
scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
if (this.props.onHeightChange && heightJustChanged) {
this.props.onHeightChange();
}
}
}
setRef = c => {
this.node = c;
this._measureHeight();
};
componentDidUpdate (prevProps, prevState) {
this._measureHeight(prevState.height !== this.state.height);
}
handleModalLink = e => {
e.preventDefault();
let href;
if (e.target.nodeName !== 'A') {
href = e.target.parentNode.href;
} else {
href = e.target.href;
}
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
};
handleTranslate = (sourceLanguage) => {
const { onTranslate, status } = this.props;
onTranslate(status, sourceLanguage);
};
handleUndoStatusTranslation = () => {
const { onUndoStatusTranslation, status } = this.props;
onUndoStatusTranslation(status);
};
_properStatus () {
const { status } = this.props;
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
return status.get('reblog');
} else {
return status;
}
}
getAttachmentAspectRatio () {
const attachments = this._properStatus().get('media_attachments');
if (attachments.getIn([0, 'type']) === 'video') {
return `${attachments.getIn([0, 'meta', 'original', 'width'])} / ${attachments.getIn([0, 'meta', 'original', 'height'])}`;
} else if (attachments.getIn([0, 'type']) === 'audio') {
return '16 / 9';
} else {
return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2';
}
}
render () {
const status = this._properStatus();
const outerStyle = { boxSizing: 'border-box' };
const { compact, pictureInPicture } = this.props;
if (!status) {
return null;
}
let media = '';
let applicationLink = '';
let reblogLink = '';
let favouriteLink = '';
if (this.props.measureHeight) {
outerStyle.height = `${this.state.height}px`;
}
const language = status.getIn(['translation', 'language']) || status.get('language');
if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder aspectRatio={this.getAttachmentAspectRatio()} />;
} else if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
media = (
<Audio
src={attachment.get('url')}
alt={description}
lang={language}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
sensitive={status.get('sensitive')}
visible={this.props.showMedia}
blurhash={attachment.get('blurhash')}
height={150}
onToggleVisibility={this.props.onToggleMediaVisibility}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
media = (
<Video
preview={attachment.get('preview_url')}
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
aspectRatio={`${attachment.getIn(['meta', 'original', 'width'])} / ${attachment.getIn(['meta', 'original', 'height'])}`}
blurhash={attachment.get('blurhash')}
src={attachment.get('url')}
alt={description}
lang={language}
width={300}
height={150}
onOpenVideo={this.handleOpenVideo}
sensitive={status.get('sensitive')}
visible={this.props.showMedia}
onToggleVisibility={this.props.onToggleMediaVisibility}
/>
);
} else {
media = (
<MediaGallery
standalone
sensitive={status.get('sensitive')}
media={status.get('media_attachments')}
lang={language}
height={300}
onOpenMedia={this.props.onOpenMedia}
visible={this.props.showMedia}
onToggleVisibility={this.props.onToggleMediaVisibility}
/>
);
}
} else if (status.get('spoiler_text').length === 0) {
media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
}
if (status.get('application')) {
applicationLink = <>·<a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></>;
}
const visibilityLink = <>·<VisibilityIcon visibility={status.get('visibility')} /></>;
if (['private', 'direct'].includes(status.get('visibility'))) {
reblogLink = '';
} else if (this.props.history) {
reblogLink = (
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'>
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
<FormattedMessage id='status.reblogs' defaultMessage='{count, plural, one {boost} other {boosts}}' values={{ count: status.get('reblogs_count') }} />
</Link>
);
} else {
reblogLink = (
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
<FormattedMessage id='status.reblogs' defaultMessage='{count, plural, one {boost} other {boosts}}' values={{ count: status.get('reblogs_count') }} />
</a>
);
}
if (this.props.history) {
favouriteLink = (
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`} className='detailed-status__link'>
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('favourites_count')} />
</span>
<FormattedMessage id='status.favourites' defaultMessage='{count, plural, one {favorite} other {favorites}}' values={{ count: status.get('favourites_count') }} />
</Link>
);
} else {
favouriteLink = (
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('favourites_count')} />
</span>
<FormattedMessage id='status.favourites' defaultMessage='{count, plural, one {favorite} other {favorites}}' values={{ count: status.get('favourites_count') }} />
</a>
);
}
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0;
return (
<div style={outerStyle}>
<div ref={this.setRef} className={classNames('detailed-status', { compact })}>
{status.get('visibility') === 'direct' && (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='at' icon={AlternateEmailIcon} className='status__prepend-icon' /></div>
<FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
</div>
)}
<a href={`/@${status.getIn(['account', 'acct'])}`} data-hover-card-account={status.getIn(['account', 'id'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={46} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</a>
{status.get('spoiler_text').length > 0 && <ContentWarning text={status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml')} expanded={expanded} onClick={this.handleExpandedToggle} />}
{expanded && (
<>
<StatusContent
status={status}
onTranslate={this.handleTranslate}
onUndoStatusTranslation={this.handleUndoStatusTranslation}
{...statusContentProps}
/>
{media}
{hashtagBar}
</>
)}
<div className='detailed-status__meta'>
<div className='detailed-status__meta__line'>
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>
<FormattedDate value={new Date(status.get('created_at'))} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>
{visibilityLink}
{applicationLink}
</div>
{status.get('edited_at') && <div className='detailed-status__meta__line'><EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} /></div>}
<div className='detailed-status__meta__line'>
{reblogLink}
{reblogLink && <>·</>}
{favouriteLink}
</div>
</div>
</div>
</div>
);
}
}
export default withRouter(DetailedStatus);

View File

@ -0,0 +1,400 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access,
@typescript-eslint/no-unsafe-call,
@typescript-eslint/no-explicit-any,
@typescript-eslint/no-unsafe-assignment */
import type { CSSProperties } from 'react';
import { useState, useRef, useCallback } from 'react';
import { FormattedDate, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { AnimatedNumber } from 'mastodon/components/animated_number';
import { ContentWarning } from 'mastodon/components/content_warning';
import EditedTimestamp from 'mastodon/components/edited_timestamp';
import type { StatusLike } from 'mastodon/components/hashtag_bar';
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
import { Icon } from 'mastodon/components/icon';
import { IconLogo } from 'mastodon/components/logo';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import MediaGallery from '../../../components/media_gallery';
import StatusContent from '../../../components/status_content';
import Audio from '../../audio';
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import Video from '../../video';
import Card from './card';
interface VideoModalOptions {
startTime: number;
autoPlay?: boolean;
defaultVolume: number;
componentIndex: number;
}
export const DetailedStatus: React.FC<{
status: any;
onOpenMedia?: (status: any, index: number, lang: string) => void;
onOpenVideo?: (status: any, lang: string, options: VideoModalOptions) => void;
onTranslate?: (status: any, sourceLanguage?: string) => void;
onUndoStatusTranslation?: (status: any) => void;
measureHeight?: boolean;
onHeightChange?: () => void;
domain: string;
showMedia?: boolean;
withLogo?: boolean;
pictureInPicture: any;
onToggleHidden?: (status: any) => void;
onToggleMediaVisibility?: () => void;
}> = ({
status,
onOpenMedia,
onOpenVideo,
onTranslate,
onUndoStatusTranslation,
measureHeight,
onHeightChange,
domain,
showMedia,
withLogo,
pictureInPicture,
onToggleMediaVisibility,
onToggleHidden,
}) => {
const properStatus = status?.get('reblog') ?? status;
const [height, setHeight] = useState(0);
const nodeRef = useRef<HTMLDivElement>();
const handleOpenVideo = useCallback(
(options: VideoModalOptions) => {
const lang = (status.getIn(['translation', 'language']) ||
status.get('language')) as string;
if (onOpenVideo)
onOpenVideo(status.getIn(['media_attachments', 0]), lang, options);
},
[onOpenVideo, status],
);
const handleExpandedToggle = useCallback(() => {
if (onToggleHidden) onToggleHidden(status);
}, [onToggleHidden, status]);
const _measureHeight = useCallback(
(heightJustChanged?: boolean) => {
if (measureHeight && nodeRef.current) {
scheduleIdleTask(() => {
if (nodeRef.current)
setHeight(Math.ceil(nodeRef.current.scrollHeight) + 1);
});
if (onHeightChange && heightJustChanged) {
onHeightChange();
}
}
},
[onHeightChange, measureHeight, setHeight],
);
const handleRef = useCallback(
(c: HTMLDivElement) => {
nodeRef.current = c;
_measureHeight();
},
[_measureHeight],
);
const handleTranslate = useCallback(
(sourceLanguage?: string) => {
if (onTranslate) onTranslate(status, sourceLanguage);
},
[onTranslate, status],
);
const handleUndoStatusTranslation = useCallback(() => {
if (onUndoStatusTranslation) onUndoStatusTranslation(status);
}, [onUndoStatusTranslation, status]);
if (!properStatus) {
return null;
}
let media;
let applicationLink;
let reblogLink;
let attachmentAspectRatio;
if (properStatus.get('media_attachments').getIn([0, 'type']) === 'video') {
attachmentAspectRatio = `${properStatus.get('media_attachments').getIn([0, 'meta', 'original', 'width'])} / ${properStatus.get('media_attachments').getIn([0, 'meta', 'original', 'height'])}`;
} else if (
properStatus.get('media_attachments').getIn([0, 'type']) === 'audio'
) {
attachmentAspectRatio = '16 / 9';
} else {
attachmentAspectRatio =
properStatus.get('media_attachments').size === 1 &&
properStatus
.get('media_attachments')
.getIn([0, 'meta', 'small', 'aspect'])
? properStatus
.get('media_attachments')
.getIn([0, 'meta', 'small', 'aspect'])
: '3 / 2';
}
const outerStyle = { boxSizing: 'border-box' } as CSSProperties;
if (measureHeight) {
outerStyle.height = height;
}
const language =
status.getIn(['translation', 'language']) || status.get('language');
if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder aspectRatio={attachmentAspectRatio} />;
} else if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
const description =
attachment.getIn(['translation', 'description']) ||
attachment.get('description');
media = (
<Audio
src={attachment.get('url')}
alt={description}
lang={language}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
poster={
attachment.get('preview_url') ||
status.getIn(['account', 'avatar_static'])
}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
sensitive={status.get('sensitive')}
visible={showMedia}
blurhash={attachment.get('blurhash')}
height={150}
onToggleVisibility={onToggleMediaVisibility}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);
const description =
attachment.getIn(['translation', 'description']) ||
attachment.get('description');
media = (
<Video
preview={attachment.get('preview_url')}
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
aspectRatio={`${attachment.getIn(['meta', 'original', 'width'])} / ${attachment.getIn(['meta', 'original', 'height'])}`}
blurhash={attachment.get('blurhash')}
src={attachment.get('url')}
alt={description}
lang={language}
width={300}
height={150}
onOpenVideo={handleOpenVideo}
sensitive={status.get('sensitive')}
visible={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
);
} else {
media = (
<MediaGallery
standalone
sensitive={status.get('sensitive')}
media={status.get('media_attachments')}
lang={language}
height={300}
onOpenMedia={onOpenMedia}
visible={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
);
}
} else if (status.get('spoiler_text').length === 0) {
media = (
<Card
sensitive={status.get('sensitive')}
onOpenMedia={onOpenMedia}
card={status.get('card', null)}
/>
);
}
if (status.get('application')) {
applicationLink = (
<>
·
<a
className='detailed-status__application'
href={status.getIn(['application', 'website'])}
target='_blank'
rel='noopener noreferrer'
>
{status.getIn(['application', 'name'])}
</a>
</>
);
}
const visibilityLink = (
<>
·<VisibilityIcon visibility={status.get('visibility')} />
</>
);
if (['private', 'direct'].includes(status.get('visibility') as string)) {
reblogLink = '';
} else {
reblogLink = (
<Link
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`}
className='detailed-status__link'
>
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
<FormattedMessage
id='status.reblogs'
defaultMessage='{count, plural, one {boost} other {boosts}}'
values={{ count: status.get('reblogs_count') }}
/>
</Link>
);
}
const favouriteLink = (
<Link
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`}
className='detailed-status__link'
>
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('favourites_count')} />
</span>
<FormattedMessage
id='status.favourites'
defaultMessage='{count, plural, one {favorite} other {favorites}}'
values={{ count: status.get('favourites_count') }}
/>
</Link>
);
const { statusContentProps, hashtagBar } = getHashtagBarForStatus(
status as StatusLike,
);
const expanded =
!status.get('hidden') || status.get('spoiler_text').length === 0;
return (
<div style={outerStyle}>
<div ref={handleRef} className={classNames('detailed-status')}>
{status.get('visibility') === 'direct' && (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'>
<Icon
id='at'
icon={AlternateEmailIcon}
className='status__prepend-icon'
/>
</div>
<FormattedMessage
id='status.direct_indicator'
defaultMessage='Private mention'
/>
</div>
)}
<Link
to={`/@${status.getIn(['account', 'acct'])}`}
data-hover-card-account={status.getIn(['account', 'id'])}
className='detailed-status__display-name'
>
<div className='detailed-status__display-avatar'>
<Avatar account={status.get('account')} size={46} />
</div>
<DisplayName account={status.get('account')} localDomain={domain} />
{withLogo && (
<>
<div className='spacer' />
<IconLogo />
</>
)}
</Link>
{status.get('spoiler_text').length > 0 && (
<ContentWarning
text={
status.getIn(['translation', 'spoilerHtml']) ||
status.get('spoilerHtml')
}
expanded={expanded}
onClick={handleExpandedToggle}
/>
)}
{expanded && (
<>
<StatusContent
status={status}
onTranslate={handleTranslate}
onUndoStatusTranslation={handleUndoStatusTranslation}
{...(statusContentProps as any)}
/>
{media}
{hashtagBar}
</>
)}
<div className='detailed-status__meta'>
<div className='detailed-status__meta__line'>
<a
className='detailed-status__datetime'
href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`}
target='_blank'
rel='noopener noreferrer'
>
<FormattedDate
value={new Date(status.get('created_at') as string)}
year='numeric'
month='short'
day='2-digit'
hour='2-digit'
minute='2-digit'
/>
</a>
{visibilityLink}
{applicationLink}
</div>
{status.get('edited_at') && (
<div className='detailed-status__meta__line'>
<EditedTimestamp
statusId={status.get('id')}
timestamp={status.get('edited_at')}
/>
</div>
)}
<div className='detailed-status__meta__line'>
{reblogLink}
{reblogLink && <>·</>}
{favouriteLink}
</div>
</div>
</div>
</div>
);
};

View File

@ -1,140 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { showAlertForError } from '../../../actions/alerts';
import { initBlockModal } from '../../../actions/blocks';
import {
replyCompose,
mentionCompose,
directCompose,
} from '../../../actions/compose';
import {
toggleReblog,
toggleFavourite,
pin,
unpin,
} from '../../../actions/interactions';
import { openModal } from '../../../actions/modal';
import { initMuteModal } from '../../../actions/mutes';
import { initReport } from '../../../actions/reports';
import {
muteStatus,
unmuteStatus,
deleteStatus,
toggleStatusSpoilers,
} from '../../../actions/statuses';
import { deleteModal } from '../../../initial_state';
import { makeGetStatus, makeGetPictureInPicture } from '../../../selectors';
import DetailedStatus from '../components/detailed_status';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture();
const mapStateToProps = (state, props) => ({
status: getStatus(state, props),
domain: state.getIn(['meta', 'domain']),
pictureInPicture: getPictureInPicture(state, props),
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch) => ({
onReply (status) {
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal({ modalType: 'CONFIRM_REPLY', modalProps: { status } }));
} else {
dispatch(replyCompose(status));
}
});
},
onReblog (status, e) {
dispatch(toggleReblog(status.get('id'), e.shiftKey));
},
onFavourite (status) {
dispatch(toggleFavourite(status.get('id')));
},
onPin (status) {
if (status.get('pinned')) {
dispatch(unpin(status));
} else {
dispatch(pin(status));
}
},
onEmbed (status) {
dispatch(openModal({
modalType: 'EMBED',
modalProps: {
id: status.get('id'),
onError: error => dispatch(showAlertForError(error)),
},
}));
},
onDelete (status, withRedraft = false) {
if (!deleteModal) {
dispatch(deleteStatus(status.get('id'), withRedraft));
} else {
dispatch(openModal({ modalType: 'CONFIRM_DELETE_STATUS', modalProps: { statusId: status.get('id'), withRedraft } }));
}
},
onDirect (account) {
dispatch(directCompose(account));
},
onMention (account) {
dispatch(mentionCompose(account));
},
onOpenMedia (media, index, lang) {
dispatch(openModal({
modalType: 'MEDIA',
modalProps: { media, index, lang },
}));
},
onOpenVideo (media, lang, options) {
dispatch(openModal({
modalType: 'VIDEO',
modalProps: { media, lang, options },
}));
},
onBlock (status) {
const account = status.get('account');
dispatch(initBlockModal(account));
},
onReport (status) {
dispatch(initReport(status.get('account'), status));
},
onMute (account) {
dispatch(initMuteModal(account));
},
onMuteConversation (status) {
if (status.get('muted')) {
dispatch(unmuteStatus(status.get('id')));
} else {
dispatch(muteStatus(status.get('id')));
}
},
onToggleHidden (status) {
dispatch(toggleStatusSpoilers(status.get('id')));
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));

View File

@ -69,7 +69,7 @@ import Column from '../ui/components/column';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
import ActionBar from './components/action_bar'; import ActionBar from './components/action_bar';
import DetailedStatus from './components/detailed_status'; import { DetailedStatus } from './components/detailed_status';
const messages = defineMessages({ const messages = defineMessages({

View File

@ -99,7 +99,7 @@ export const BlockModal = ({ accountId, acct }) => {
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
</button> </button>
<Button onClick={handleClick}> <Button onClick={handleClick} autoFocus>
<FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' /> <FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' />
</Button> </Button>
</div> </div>

View File

@ -1,28 +1,17 @@
import type { MouseEventHandler } from 'react';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { useHistory } from 'react-router';
import type Immutable from 'immutable';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import AttachmentList from 'mastodon/components/attachment_list'; import { Button } from 'mastodon/components/button';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
import PrivacyDropdown from 'mastodon/features/compose/components/privacy_dropdown'; import PrivacyDropdown from 'mastodon/features/compose/components/privacy_dropdown';
import type { Account } from 'mastodon/models/account'; import { EmbeddedStatus } from 'mastodon/features/notifications_v2/components/embedded_status';
import type { Status, StatusVisibility } from 'mastodon/models/status'; import type { Status, StatusVisibility } from 'mastodon/models/status';
import { useAppSelector } from 'mastodon/store'; import { useAppSelector } from 'mastodon/store';
import { Avatar } from '../../../components/avatar';
import { Button } from '../../../components/button';
import { DisplayName } from '../../../components/display_name';
import { RelativeTimestamp } from '../../../components/relative_timestamp';
import StatusContent from '../../../components/status_content';
const messages = defineMessages({ const messages = defineMessages({
cancel_reblog: { cancel_reblog: {
id: 'status.cancel_reblog_private', id: 'status.cancel_reblog_private',
@ -37,18 +26,17 @@ export const BoostModal: React.FC<{
onReblog: (status: Status, privacy: StatusVisibility) => void; onReblog: (status: Status, privacy: StatusVisibility) => void;
}> = ({ status, onReblog, onClose }) => { }> = ({ status, onReblog, onClose }) => {
const intl = useIntl(); const intl = useIntl();
const history = useHistory();
const default_privacy = useAppSelector( const defaultPrivacy = useAppSelector(
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
(state) => state.compose.get('default_privacy') as StatusVisibility, (state) => state.compose.get('default_privacy') as StatusVisibility,
); );
const account = status.get('account') as Account; const statusId = status.get('id') as string;
const statusVisibility = status.get('visibility') as StatusVisibility; const statusVisibility = status.get('visibility') as StatusVisibility;
const [privacy, setPrivacy] = useState<StatusVisibility>( const [privacy, setPrivacy] = useState<StatusVisibility>(
statusVisibility === 'private' ? 'private' : default_privacy, statusVisibility === 'private' ? 'private' : defaultPrivacy,
); );
const onPrivacyChange = useCallback((value: StatusVisibility) => { const onPrivacyChange = useCallback((value: StatusVisibility) => {
@ -60,20 +48,9 @@ export const BoostModal: React.FC<{
onClose(); onClose();
}, [onClose, onReblog, status, privacy]); }, [onClose, onReblog, status, privacy]);
const handleAccountClick = useCallback<MouseEventHandler>( const handleCancel = useCallback(() => {
(e) => { onClose();
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { }, [onClose]);
e.preventDefault();
onClose();
history.push(`/@${account.acct}`);
}
},
[history, onClose, account],
);
const buttonText = status.get('reblogged')
? messages.cancel_reblog
: messages.reblog;
const findContainer = useCallback( const findContainer = useCallback(
() => document.getElementsByClassName('modal-root__container')[0], () => document.getElementsByClassName('modal-root__container')[0],
@ -81,81 +58,78 @@ export const BoostModal: React.FC<{
); );
return ( return (
<div className='modal-root__modal boost-modal'> <div className='modal-root__modal safety-action-modal'>
<div className='boost-modal__container'> <div className='safety-action-modal__top'>
<div <div className='safety-action-modal__header'>
className={classNames( <div className='safety-action-modal__header__icon'>
'status', <Icon icon={RepeatIcon} id='retweet' />
`status-${statusVisibility}`,
'light',
)}
>
<div className='status__info'>
<a
href={`/@${account.acct}/${status.get('id') as string}`}
className='status__relative-time'
target='_blank'
rel='noopener noreferrer'
>
<span className='status__visibility-icon'>
<VisibilityIcon visibility={statusVisibility} />
</span>
<RelativeTimestamp
timestamp={status.get('created_at') as string}
/>
</a>
<a
onClick={handleAccountClick}
href={`/@${account.acct}`}
className='status__display-name'
>
<div className='status__avatar'>
<Avatar account={account} size={48} />
</div>
<DisplayName account={account} />
</a>
</div> </div>
{/* @ts-expect-error Expected until StatusContent is typed */} <div>
<StatusContent status={status} /> <h1>
{status.get('reblogged') ? (
<FormattedMessage
id='boost_modal.undo_reblog'
defaultMessage='Unboost post?'
/>
) : (
<FormattedMessage
id='boost_modal.reblog'
defaultMessage='Boost post?'
/>
)}
</h1>
<div>
<FormattedMessage
id='boost_modal.combo'
defaultMessage='You can press {combo} to skip this next time'
values={{
combo: (
<span className='hotkey-combination'>
<kbd>Shift</kbd>+<Icon id='retweet' icon={RepeatIcon} />
</span>
),
}}
/>
</div>
</div>
</div>
{(status.get('media_attachments') as Immutable.List<unknown>).size > <div className='safety-action-modal__status'>
0 && ( <EmbeddedStatus statusId={statusId} />
<AttachmentList compact media={status.get('media_attachments')} />
)}
</div> </div>
</div> </div>
<div className='boost-modal__action-bar'> <div className={classNames('safety-action-modal__bottom')}>
<div> <div className='safety-action-modal__actions'>
<FormattedMessage {!status.get('reblogged') && (
id='boost_modal.combo' <PrivacyDropdown
defaultMessage='You can press {combo} to skip this next time' noDirect
values={{ value={privacy}
combo: ( container={findContainer}
<span> onChange={onPrivacyChange}
Shift + <Icon id='retweet' icon={RepeatIcon} /> disabled={statusVisibility === 'private'}
</span> />
), )}
}}
<div className='spacer' />
<button onClick={handleCancel} className='link-button'>
<FormattedMessage
id='confirmation_modal.cancel'
defaultMessage='Cancel'
/>
</button>
<Button
onClick={handleReblog}
text={intl.formatMessage(
status.get('reblogged')
? messages.cancel_reblog
: messages.reblog,
)}
/> />
</div> </div>
{statusVisibility !== 'private' && !status.get('reblogged') && (
<PrivacyDropdown
noDirect
value={privacy}
container={findContainer}
onChange={onPrivacyChange}
/>
)}
<Button
text={intl.formatMessage(buttonText)}
onClick={handleReblog}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
</div> </div>
</div> </div>
); );

View File

@ -71,7 +71,10 @@ export const ConfirmationModal: React.FC<
/> />
</button> </button>
<Button onClick={handleClick}>{confirm}</Button> {/* eslint-disable-next-line jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */}
<Button onClick={handleClick} autoFocus>
{confirm}
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -88,7 +88,7 @@ export const DomainBlockModal = ({ domain, accountId, acct }) => {
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
</button> </button>
<Button onClick={handleClick}> <Button onClick={handleClick} autoFocus>
<FormattedMessage id='domain_block_modal.block' defaultMessage='Block server' /> <FormattedMessage id='domain_block_modal.block' defaultMessage='Block server' />
</Button> </Button>
</div> </div>

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