Merge branch 'main' into patch-1

pull/33619/head
Colin Dean 3 weeks ago committed by GitHub
commit 62c3b11c8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -10,6 +10,7 @@ services:
RAILS_ENV: development
NODE_ENV: development
BIND: 0.0.0.0
BOOTSNAP_CACHE_DIR: /tmp
REDIS_HOST: redis
REDIS_PORT: '6379'
DB_HOST: db

@ -1,14 +1,9 @@
on:
workflow_call:
inputs:
platforms:
required: true
type: string
cache:
type: boolean
default: true
use_native_arm64_builder:
type: boolean
push_to_images:
type: string
version_prerelease:
@ -24,42 +19,36 @@ on:
file_to_build:
type: string
# This builds multiple images with one runner each, allowing us to build for multiple architectures
# using Github's runners.
# The two-step process is adapted form:
# https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners
jobs:
# Build each (amd64 and arm64) image separately
build-image:
runs-on: ubuntu-latest
runs-on: ${{ startsWith(matrix.platform, 'linux/arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
if: contains(inputs.platforms, 'linux/arm64') && !inputs.use_native_arm64_builder
- uses: docker/setup-buildx-action@v3
id: buildx
if: ${{ !(inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64')) }}
- name: Start a local Docker Builder
if: inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64')
- name: Prepare
env:
PUSH_TO_IMAGES: ${{ inputs.push_to_images }}
run: |
docker run --rm -d --name buildkitd -p 1234:1234 --privileged moby/buildkit:latest --addr tcp://0.0.0.0:1234
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
# Transform multi-line variable into comma-separated variable
image_names=${PUSH_TO_IMAGES//$'\n'/,}
echo "IMAGE_NAMES=${image_names%,}" >> $GITHUB_ENV
- uses: docker/setup-buildx-action@v3
id: buildx-native
if: inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64')
with:
driver: remote
endpoint: tcp://localhost:1234
platforms: linux/amd64
append: |
- endpoint: tcp://${{ vars.DOCKER_BUILDER_HETZNER_ARM64_01_HOST }}:13865
platforms: linux/arm64
name: mastodon-docker-builder-arm64-01
driver-opts:
- servername=mastodon-docker-builder-arm64-01
env:
BUILDER_NODE_1_AUTH_TLS_CACERT: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_CACERT }}
BUILDER_NODE_1_AUTH_TLS_CERT: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_CERT }}
BUILDER_NODE_1_AUTH_TLS_KEY: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_KEY }}
id: buildx
- name: Log in to Docker Hub
if: contains(inputs.push_to_images, 'tootsuite')
@ -76,16 +65,18 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
if: ${{ inputs.push_to_images != '' }}
with:
images: ${{ inputs.push_to_images }}
flavor: ${{ inputs.flavor }}
tags: ${{ inputs.tags }}
labels: ${{ inputs.labels }}
- uses: docker/build-push-action@v6
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: ${{ inputs.file_to_build }}
@ -93,11 +84,87 @@ jobs:
MASTODON_VERSION_PRERELEASE=${{ inputs.version_prerelease }}
MASTODON_VERSION_METADATA=${{ inputs.version_metadata }}
SOURCE_COMMIT=${{ github.sha }}
platforms: ${{ inputs.platforms }}
platforms: ${{ matrix.platform }}
provenance: false
builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }}
push: ${{ inputs.push_to_images != '' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: ${{ inputs.cache && 'type=gha' || '' }}
cache-to: ${{ inputs.cache && 'type=gha,mode=max' || '' }}
outputs: type=image,"name=${{ env.IMAGE_NAMES }}",push-by-digest=true,name-canonical=true,push=${{ inputs.push_to_images != '' }}
- name: Export digest
if: ${{ inputs.push_to_images != '' }}
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
if: ${{ inputs.push_to_images != '' }}
uses: actions/upload-artifact@v4
with:
# `hashFiles` is used to disambiguate between streaming and non-streaming images
name: digests-${{ hashFiles(inputs.file_to_build) }}-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
# Then merge the docker images into a single one
merge-images:
if: ${{ inputs.push_to_images != '' }}
runs-on: ubuntu-24.04
needs:
- build-image
env:
PUSH_TO_IMAGES: ${{ inputs.push_to_images }}
steps:
- uses: actions/checkout@v4
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
# `hashFiles` is used to disambiguate between streaming and non-streaming images
pattern: digests-${{ hashFiles(inputs.file_to_build) }}-*
merge-multiple: true
- name: Log in to Docker Hub
if: contains(inputs.push_to_images, 'tootsuite')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
if: contains(inputs.push_to_images, 'ghcr.io')
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
if: ${{ inputs.push_to_images != '' }}
with:
images: ${{ inputs.push_to_images }}
flavor: ${{ inputs.flavor }}
tags: ${{ inputs.tags }}
labels: ${{ inputs.labels }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
echo "$PUSH_TO_IMAGES" | xargs -I{} \
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '{}@sha256:%s ' *)
- name: Inspect image
run: |
echo "$PUSH_TO_IMAGES" | xargs -i{} \
docker buildx imagetools inspect {}:${{ steps.meta.outputs.version }}

@ -26,8 +26,6 @@ jobs:
uses: ./.github/workflows/build-container-image.yml
with:
file_to_build: Dockerfile
platforms: linux/amd64,linux/arm64
use_native_arm64_builder: true
cache: false
push_to_images: |
tootsuite/mastodon
@ -48,8 +46,6 @@ jobs:
uses: ./.github/workflows/build-container-image.yml
with:
file_to_build: streaming/Dockerfile
platforms: linux/amd64,linux/arm64
use_native_arm64_builder: true
cache: false
push_to_images: |
tootsuite/mastodon-streaming

@ -32,8 +32,6 @@ jobs:
uses: ./.github/workflows/build-container-image.yml
with:
file_to_build: Dockerfile
platforms: linux/amd64,linux/arm64
use_native_arm64_builder: true
push_to_images: |
ghcr.io/mastodon/mastodon
version_metadata: ${{ needs.compute-suffix.outputs.metadata }}
@ -49,8 +47,6 @@ jobs:
uses: ./.github/workflows/build-container-image.yml
with:
file_to_build: streaming/Dockerfile
platforms: linux/amd64,linux/arm64
use_native_arm64_builder: true
push_to_images: |
ghcr.io/mastodon/mastodon-streaming
version_metadata: ${{ needs.compute-suffix.outputs.metadata }}

@ -13,8 +13,6 @@ jobs:
uses: ./.github/workflows/build-container-image.yml
with:
file_to_build: Dockerfile
platforms: linux/amd64,linux/arm64
use_native_arm64_builder: true
push_to_images: |
tootsuite/mastodon
ghcr.io/mastodon/mastodon
@ -34,8 +32,6 @@ jobs:
uses: ./.github/workflows/build-container-image.yml
with:
file_to_build: streaming/Dockerfile
platforms: linux/amd64,linux/arm64
use_native_arm64_builder: true
push_to_images: |
tootsuite/mastodon-streaming
ghcr.io/mastodon/mastodon-streaming

@ -24,8 +24,6 @@ jobs:
uses: ./.github/workflows/build-container-image.yml
with:
file_to_build: Dockerfile
platforms: linux/amd64,linux/arm64
use_native_arm64_builder: true
cache: false
push_to_images: |
tootsuite/mastodon
@ -46,8 +44,6 @@ jobs:
uses: ./.github/workflows/build-container-image.yml
with:
file_to_build: streaming/Dockerfile
platforms: linux/amd64,linux/arm64
use_native_arm64_builder: true
cache: false
push_to_images: |
tootsuite/mastodon-streaming

@ -20,7 +20,6 @@ jobs:
uses: ./.github/workflows/build-container-image.yml
with:
file_to_build: Dockerfile
platforms: linux/amd64 # Testing only on native platform so it is performant
cache: true
build-image-streaming:
@ -31,5 +30,4 @@ jobs:
uses: ./.github/workflows/build-container-image.yml
with:
file_to_build: streaming/Dockerfile
platforms: linux/amd64 # Testing only on native platform so it is performant
cache: true

@ -64,7 +64,6 @@ jobs:
DB_HOST: localhost
DB_USER: postgres
DB_PASS: postgres
DISABLE_SIMPLECOV: true
RAILS_ENV: test
BUNDLE_CLEAN: true
BUNDLE_FROZEN: true

@ -107,7 +107,7 @@ jobs:
DB_HOST: localhost
DB_USER: postgres
DB_PASS: postgres
DISABLE_SIMPLECOV: ${{ matrix.ruby-version != '.ruby-version' }}
COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }}
RAILS_ENV: test
ALLOW_NOPAM: true
PAM_ENABLED: true
@ -208,7 +208,7 @@ jobs:
DB_HOST: localhost
DB_USER: postgres
DB_PASS: postgres
DISABLE_SIMPLECOV: ${{ matrix.ruby-version != '.ruby-version' }}
COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }}
RAILS_ENV: test
ALLOW_NOPAM: true
PAM_ENABLED: true
@ -295,7 +295,6 @@ jobs:
DB_HOST: localhost
DB_USER: postgres
DB_PASS: postgres
DISABLE_SIMPLECOV: true
RAILS_ENV: test
BUNDLE_WITH: test
LOCAL_DOMAIN: localhost:3000
@ -411,7 +410,6 @@ jobs:
DB_HOST: localhost
DB_USER: postgres
DB_PASS: postgres
DISABLE_SIMPLECOV: true
RAILS_ENV: test
BUNDLE_WITH: test
ES_ENABLED: true

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1.12
# This file is designed for production server deployment, not local development work
# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker
# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/docs/DEVELOPMENT.md#docker
# Please see https://docs.docker.com/engine/reference/builder for information about
# the extended buildx capabilities used in this file.
@ -9,6 +9,7 @@
# See: https://docs.docker.com/build/building/multi-platform/
ARG TARGETPLATFORM=${TARGETPLATFORM}
ARG BUILDPLATFORM=${BUILDPLATFORM}
ARG BASE_REGISTRY="docker.io"
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
# renovate: datasource=docker depName=docker.io/ruby
@ -19,9 +20,9 @@ ARG NODE_MAJOR_VERSION="22"
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
ARG DEBIAN_VERSION="bookworm"
# Node image to use for base image based on combined variables (ex: 20-bookworm-slim)
FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node
FROM ${BASE_REGISTRY}/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node
# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-bookworm)
FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
# Example: v4.3.0-nightly.2023.11.09+pr-123456

@ -100,6 +100,8 @@ gem 'json-ld'
gem 'json-ld-preloaded', '~> 3.2'
gem 'rdf-normalize', '~> 0.5'
gem 'prometheus_exporter', '~> 2.2', require: false
gem 'opentelemetry-api', '~> 1.4.0'
group :opentelemetry do
@ -114,7 +116,7 @@ group :opentelemetry do
gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.26.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.35.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.36.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false
gem 'opentelemetry-sdk', '~> 1.4', require: false
@ -154,7 +156,7 @@ group :test do
gem 'shoulda-matchers'
# Coverage formatter for RSpec test if DISABLE_SIMPLECOV is false
# Coverage formatter for RSpec
gem 'simplecov', '~> 0.22', require: false
gem 'simplecov-lcov', '~> 0.8', require: false
@ -172,7 +174,7 @@ group :development do
gem 'rubocop-rspec_rails', require: false
# Annotates modules with schema
gem 'annotaterb', '~> 4.13'
gem 'annotaterb', '~> 4.13', require: false
# Enhanced error message pages for development
gem 'better_errors', '~> 2.9'
@ -195,7 +197,7 @@ end
group :development, :test do
# Interactive Debugging tools
gem 'debug', '~> 1.8'
gem 'debug', '~> 1.8', require: false
# Generate fake data values
gem 'faker', '~> 3.2'
@ -207,7 +209,7 @@ group :development, :test do
gem 'memory_profiler', require: false
gem 'ruby-prof', require: false
gem 'stackprof', require: false
gem 'test-prof'
gem 'test-prof', require: false
# RSpec runner for rails
gem 'rspec-rails', '~> 7.0'

@ -220,7 +220,7 @@ GEM
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
excon (0.112.0)
excon (1.2.3)
fabrication (2.31.0)
faker (3.5.1)
i18n (>= 1.8.11, < 2)
@ -244,15 +244,15 @@ GEM
flatware-rspec (2.3.4)
flatware (= 2.3.4)
rspec (>= 3.6)
fog-core (2.5.0)
fog-core (2.6.0)
builder
excon (~> 0.71)
excon (~> 1.0)
formatador (>= 0.2, < 2.0)
mime-types
fog-json (1.2.0)
fog-core
multi_json (~> 1.10)
fog-openstack (1.1.3)
fog-openstack (1.1.4)
fog-core (~> 2.1)
fog-json (>= 1.0)
formatador (1.1.0)
@ -273,7 +273,7 @@ GEM
activesupport (>= 5.1)
haml (>= 4.0.6)
railties (>= 5.1)
haml_lint (0.59.0)
haml_lint (0.60.0)
haml (>= 5.0)
parallel (~> 1.10)
rainbow
@ -283,7 +283,7 @@ GEM
hashie (5.0.0)
hcaptcha (7.1.0)
json
highline (3.1.1)
highline (3.1.2)
reline
hiredis (0.6.3)
hkdf (0.3.0)
@ -302,7 +302,7 @@ GEM
httplog (1.7.0)
rack (>= 2.0)
rainbow (>= 2.0.0)
i18n (1.14.6)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.14)
activesupport (>= 4.0.2)
@ -319,7 +319,8 @@ GEM
activesupport (>= 3.0)
nokogiri (>= 1.6)
io-console (0.8.0)
irb (1.14.3)
irb (1.15.1)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jd-paperclip-azure (3.0.0)
@ -349,7 +350,7 @@ GEM
addressable (~> 2.8)
bigdecimal (~> 3.1)
jsonapi-renderer (0.2.2)
jwt (2.9.3)
jwt (2.10.1)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
@ -369,7 +370,7 @@ GEM
marcel (~> 1.0.1)
mime-types
terrapin (>= 0.6.0, < 2.0)
language_server-protocol (3.17.0.3)
language_server-protocol (3.17.0.4)
launchy (3.0.1)
addressable (~> 2.8)
childprocess (~> 5.0)
@ -384,7 +385,7 @@ GEM
llhttp-ffi (0.5.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
logger (1.6.4)
logger (1.6.5)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
@ -406,7 +407,7 @@ GEM
mime-types (3.6.0)
logger
mime-types-data (~> 3.2015)
mime-types-data (3.2024.1203)
mime-types-data (3.2025.0204)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
minitest (5.25.4)
@ -415,7 +416,7 @@ GEM
mutex_m (0.3.0)
net-http (0.6.0)
uri
net-imap (0.5.4)
net-imap (0.5.5)
date
net-protocol
net-ldap (0.19.0)
@ -426,7 +427,7 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.1)
nokogiri (1.18.2)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
oj (3.16.9)
@ -460,7 +461,7 @@ GEM
validate_email
validate_url
webfinger (~> 1.2)
openssl (3.2.1)
openssl (3.3.0)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
opentelemetry-api (1.4.0)
@ -479,7 +480,7 @@ GEM
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-action_pack (0.11.0)
opentelemetry-instrumentation-action_pack (0.12.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-rack (~> 0.21)
@ -497,6 +498,10 @@ GEM
opentelemetry-instrumentation-active_record (0.9.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-active_storage (0.1.0)
opentelemetry-api (~> 1.4.0)
opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-active_support (0.8.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
@ -529,17 +534,18 @@ GEM
opentelemetry-instrumentation-rack (0.26.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-rails (0.35.0)
opentelemetry-instrumentation-rails (0.36.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-action_mailer (~> 0.4.0)
opentelemetry-instrumentation-action_pack (~> 0.11.0)
opentelemetry-instrumentation-action_pack (~> 0.12.0)
opentelemetry-instrumentation-action_view (~> 0.9.0)
opentelemetry-instrumentation-active_job (~> 0.8.0)
opentelemetry-instrumentation-active_record (~> 0.9.0)
opentelemetry-instrumentation-active_storage (~> 0.1.0)
opentelemetry-instrumentation-active_support (~> 0.8.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
opentelemetry-instrumentation-redis (0.26.0)
opentelemetry-instrumentation-redis (0.26.1)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-sidekiq (0.26.0)
@ -547,7 +553,7 @@ GEM
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-registry (0.3.1)
opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.6.0)
opentelemetry-sdk (1.7.0)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2)
@ -556,10 +562,10 @@ GEM
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
ostruct (0.6.1)
ox (2.14.20)
ox (2.14.21)
bigdecimal (>= 3.0)
parallel (1.26.3)
parser (3.3.6.0)
parser (3.3.7.0)
ast (~> 2.4.1)
racc
parslet (2.0.0)
@ -568,6 +574,8 @@ GEM
pg (1.5.9)
pghero (3.6.1)
activerecord (>= 6.1)
pp (0.6.2)
prettyprint
premailer (1.27.0)
addressable
css_parser (>= 1.19.0)
@ -576,16 +584,19 @@ GEM
actionmailer (>= 3)
net-smtp
premailer (~> 1.7, >= 1.7.9)
prettyprint (0.2.0)
prometheus_exporter (2.2.0)
webrick
propshaft (1.1.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
railties (>= 7.0.0)
psych (5.2.2)
psych (5.2.3)
date
stringio
public_suffix (6.0.1)
puma (6.5.0)
puma (6.6.0)
nio4r (~> 2.0)
pundit (2.4.0)
activesupport (>= 3.0.0)
@ -658,7 +669,7 @@ GEM
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.7.0)
rdf (~> 3.3)
rdoc (6.10.0)
rdoc (6.11.0)
psych (>= 4.0.0)
redcarpet (3.6.0)
redis (4.8.1)
@ -686,7 +697,7 @@ GEM
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.2)
rspec-core (3.13.3)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
diff-lcs (>= 1.2.0, < 2.0)
@ -696,7 +707,7 @@ GEM
rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.1.0)
rspec-rails (7.1.1)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
@ -710,29 +721,29 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8)
rspec-support (3.13.2)
rubocop (1.70.0)
rubocop (1.71.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.36.2, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.37.0)
rubocop-ast (1.38.0)
parser (>= 3.3.1.0)
rubocop-capybara (2.21.0)
rubocop (~> 1.41)
rubocop-performance (1.23.1)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.28.0)
rubocop-rails (2.29.1)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.3.0)
rubocop-rspec (3.4.0)
rubocop (~> 1.61)
rubocop-rspec_rails (2.30.0)
rubocop (~> 1.61)
@ -742,7 +753,7 @@ GEM
ruby-saml (1.17.0)
nokogiri (>= 1.13.10)
rexml
ruby-vips (2.2.2)
ruby-vips (2.2.3)
ffi (~> 1.12)
logger
rubyzip (2.4.1)
@ -798,8 +809,8 @@ GEM
stoplight (4.1.0)
redlock (~> 1.0)
stringio (3.1.2)
strong_migrations (2.1.0)
activerecord (>= 6.1)
strong_migrations (2.2.0)
activerecord (>= 7)
swd (1.3.0)
activesupport (>= 3)
attr_required (>= 0.0.5)
@ -812,9 +823,9 @@ GEM
climate_control
test-prof (1.4.4)
thor (1.3.2)
tilt (2.5.0)
tilt (2.6.0)
timeout (0.4.3)
tpm-key_attestation (0.12.1)
tpm-key_attestation (0.14.0)
bindata (~> 2.4)
openssl (> 2.0)
openssl-signature_algorithm (~> 1.0)
@ -849,18 +860,18 @@ GEM
public_suffix
warden (1.2.9)
rack (>= 2.0.9)
webauthn (3.2.2)
webauthn (3.3.0)
android_key_attestation (~> 0.3.0)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.1)
openssl (>= 2.2)
safety_net_attestation (~> 0.4.0)
tpm-key_attestation (~> 0.12.0)
tpm-key_attestation (~> 0.14.0)
webfinger (1.2.0)
activesupport
httpclient (>= 2.4)
webmock (3.24.0)
webmock (3.25.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@ -871,7 +882,8 @@ GEM
semantic_range (>= 2.3.0)
webrick (1.9.1)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wisper (2.0.1)
@ -970,7 +982,7 @@ DEPENDENCIES
opentelemetry-instrumentation-net_http (~> 0.23.0)
opentelemetry-instrumentation-pg (~> 0.30.0)
opentelemetry-instrumentation-rack (~> 0.26.0)
opentelemetry-instrumentation-rails (~> 0.35.0)
opentelemetry-instrumentation-rails (~> 0.36.0)
opentelemetry-instrumentation-redis (~> 0.26.0)
opentelemetry-instrumentation-sidekiq (~> 0.26.0)
opentelemetry-sdk (~> 1.4)
@ -979,6 +991,7 @@ DEPENDENCIES
pg (~> 1.5)
pghero
premailer-rails
prometheus_exporter (~> 2.2)
propshaft
public_suffix (~> 6.0)
puma (~> 6.3)
@ -1038,4 +1051,4 @@ RUBY VERSION
ruby 3.4.1p0
BUNDLED WITH
2.6.2
2.6.3

@ -34,7 +34,8 @@ module Admin
end
def resource_params
params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses)
params
.expect(admin_account_action: [:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses])
end
end
end

@ -29,10 +29,8 @@ module Admin
private
def resource_params
params.require(:account_moderation_note).permit(
:content,
:target_account_id
)
params
.expect(account_moderation_note: [:content, :target_account_id])
end
def set_account_moderation_note

@ -158,7 +158,8 @@ module Admin
end
def form_account_batch_params
params.require(:form_account_batch).permit(:action, account_ids: [])
params
.expect(form_account_batch: [:action, account_ids: []])
end
def action_from_button

@ -84,6 +84,7 @@ class Admin::AnnouncementsController < Admin::BaseController
end
def resource_params
params.require(:announcement).permit(:text, :scheduled_at, :starts_at, :ends_at, :all_day)
params
.expect(announcement: [:text, :scheduled_at, :starts_at, :ends_at, :all_day])
end
end

@ -7,17 +7,12 @@ module Admin
layout 'admin'
before_action :set_cache_headers
before_action :set_referrer_policy_header
after_action :verify_authorized
private
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end
def set_referrer_policy_header
response.headers['Referrer-Policy'] = 'same-origin'
end

@ -41,9 +41,8 @@ module Admin
end
def resource_params
params.require(:user).permit(
:unconfirmed_email
)
params
.expect(user: [:unconfirmed_email])
end
end
end

@ -44,7 +44,8 @@ module Admin
private
def resource_params
params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker)
params
.expect(custom_emoji: [:shortcode, :image, :visible_in_picker])
end
def filtered_custom_emojis
@ -74,7 +75,8 @@ module Admin
end
def form_custom_emoji_batch_params
params.require(:form_custom_emoji_batch).permit(:action, :category_id, :category_name, custom_emoji_ids: [])
params
.expect(form_custom_emoji_batch: [:action, :category_id, :category_name, custom_emoji_ids: []])
end
end
end

@ -37,6 +37,7 @@ class Admin::DomainAllowsController < Admin::BaseController
end
def resource_params
params.require(:domain_allow).permit(:domain)
params
.expect(domain_allow: [:domain])
end
end

@ -25,7 +25,9 @@ module Admin
rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.domain_blocks.not_permitted')
else
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
flash[:notice] = I18n.t('admin.domain_blocks.created_msg')
ensure
redirect_to admin_instances_path(limited: '1')
end
def new
@ -114,7 +116,12 @@ module Admin
end
def form_domain_block_batch_params
params.require(:form_domain_block_batch).permit(domain_blocks_attributes: [:enabled, :domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate])
params
.expect(
form_domain_block_batch: [
domain_blocks_attributes: [[:enabled, :domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate]],
]
)
end
def action_from_button

@ -62,11 +62,13 @@ module Admin
end
def resource_params
params.require(:email_domain_block).permit(:domain, :allow_with_approval, other_domains: [])
params
.expect(email_domain_block: [:domain, :allow_with_approval, other_domains: []])
end
def form_email_domain_block_batch_params
params.require(:form_email_domain_block_batch).permit(email_domain_block_ids: [])
params
.expect(form_email_domain_block_batch: [email_domain_block_ids: []])
end
def action_from_button

@ -37,7 +37,8 @@ module Admin
end
def form_account_batch_params
params.require(:form_account_batch).permit(:action, account_ids: [])
params
.expect(form_account_batch: [:action, account_ids: []])
end
def filter_params

@ -39,7 +39,8 @@ module Admin
private
def resource_params
params.require(:invite).permit(:max_uses, :expires_in)
params
.expect(invite: [:max_uses, :expires_in])
end
def filtered_invites

@ -44,7 +44,8 @@ module Admin
private
def resource_params
params.require(:ip_block).permit(:ip, :severity, :comment, :expires_in)
params
.expect(ip_block: [:ip, :severity, :comment, :expires_in])
end
def action_from_button
@ -52,7 +53,8 @@ module Admin
end
def form_ip_block_batch_params
params.require(:form_ip_block_batch).permit(ip_block_ids: [])
params
.expect(form_ip_block_batch: [ip_block_ids: []])
end
end
end

@ -57,7 +57,8 @@ module Admin
end
def resource_params
params.require(:relay).permit(:inbox_url)
params
.expect(relay: [:inbox_url])
end
def warn_signatures_not_enabled!

@ -47,10 +47,8 @@ module Admin
end
def resource_params
params.require(:report_note).permit(
:content,
:report_id
)
params
.expect(report_note: [:content, :report_id])
end
def set_report_note

@ -61,7 +61,8 @@ module Admin
end
def resource_params
params.require(:user_role).permit(:name, :color, :highlighted, :position, permissions_as_keys: [])
params
.expect(user_role: [:name, :color, :highlighted, :position, permissions_as_keys: []])
end
end
end

@ -53,7 +53,8 @@ module Admin
end
def resource_params
params.require(:rule).permit(:text, :hint, :priority)
params
.expect(rule: [:text, :hint, :priority])
end
end
end

@ -28,7 +28,8 @@ module Admin
end
def settings_params
params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS)
params
.expect(form_admin_settings: [*Form::AdminSettings::KEYS])
end
end
end

@ -39,7 +39,8 @@ module Admin
helper_method :batched_ordered_status_edits
def admin_status_batch_action_params
params.require(:admin_status_batch_action).permit(status_ids: [])
params
.expect(admin_status_batch_action: [status_ids: []])
end
def after_create_redirect_path

@ -37,7 +37,8 @@ module Admin
end
def tag_params
params.require(:tag).permit(:name, :display_name, :trendable, :usable, :listable)
params
.expect(tag: [:name, :display_name, :trendable, :usable, :listable])
end
def filtered_tags

@ -31,6 +31,7 @@ class Admin::TermsOfService::DraftsController < Admin::BaseController
end
def resource_params
params.require(:terms_of_service).permit(:text, :changelog)
params
.expect(terms_of_service: [:text, :changelog])
end
end

@ -32,6 +32,7 @@ class Admin::TermsOfService::GeneratesController < Admin::BaseController
end
def resource_params
params.require(:terms_of_service_generator).permit(*TermsOfService::Generator::VARIABLES)
params
.expect(terms_of_service_generator: [*TermsOfService::Generator::VARIABLES])
end
end

@ -31,7 +31,8 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll
end
def trends_preview_card_provider_batch_params
params.require(:trends_preview_card_provider_batch).permit(:action, preview_card_provider_ids: [])
params
.expect(trends_preview_card_provider_batch: [:action, preview_card_provider_ids: []])
end
def action_from_button

@ -31,7 +31,8 @@ class Admin::Trends::LinksController < Admin::BaseController
end
def trends_preview_card_batch_params
params.require(:trends_preview_card_batch).permit(:action, preview_card_ids: [])
params
.expect(trends_preview_card_batch: [:action, preview_card_ids: []])
end
def action_from_button

@ -31,7 +31,8 @@ class Admin::Trends::StatusesController < Admin::BaseController
end
def trends_status_batch_params
params.require(:trends_status_batch).permit(:action, status_ids: [])
params
.expect(trends_status_batch: [:action, status_ids: []])
end
def action_from_button

@ -31,7 +31,8 @@ class Admin::Trends::TagsController < Admin::BaseController
end
def trends_tag_batch_params
params.require(:trends_tag_batch).permit(:action, tag_ids: [])
params
.expect(trends_tag_batch: [:action, tag_ids: []])
end
def action_from_button

@ -28,7 +28,8 @@ module Admin
end
def resource_params
params.require(:user).permit(:role_id)
params
.expect(user: [:role_id])
end
end
end

@ -52,7 +52,8 @@ module Admin
end
def warning_preset_params
params.require(:account_warning_preset).permit(:title, :text)
params
.expect(account_warning_preset: [:title, :text])
end
end
end

@ -74,7 +74,8 @@ module Admin
end
def resource_params
params.require(:webhook).permit(:url, :template, events: [])
params
.expect(webhook: [:url, :template, events: []])
end
end
end

@ -56,12 +56,12 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
end
def subscription_params
params.require(:subscription).permit(:endpoint, :standard, keys: [:auth, :p256dh])
params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]])
end
def data_params
return {} if params[:data].blank?
params.require(:data).permit(:policy, alerts: Notification::TYPES)
params.expect(data: [:policy, alerts: Notification::TYPES])
end
end

@ -66,7 +66,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
end
def subscription_params
@subscription_params ||= params.require(:subscription).permit(:standard, :endpoint, keys: [:auth, :p256dh])
@subscription_params ||= params.expect(subscription: [:standard, :endpoint, keys: [:auth, :p256dh]])
end
def web_push_subscription_params
@ -82,6 +82,6 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
end
def data_params
@data_params ||= params.require(:data).permit(:policy, alerts: Notification::TYPES)
@data_params ||= params.expect(data: [:policy, alerts: Notification::TYPES])
end
end

@ -12,7 +12,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :set_sessions, only: [:edit, :update]
before_action :set_strikes, only: [:edit, :update]
before_action :require_not_suspended!, only: [:update]
before_action :set_cache_headers, only: [:edit, :update]
before_action :set_rules, only: :new
before_action :require_rules_acceptance!, only: :new
before_action :set_registration_form_time, only: :new
@ -139,10 +138,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
set_locale { render :rules }
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end
def is_flashing_format? # rubocop:disable Naming/PredicateName
if params[:action] == 'create'
false # Disable flash messages for sign-up

@ -73,7 +73,7 @@ class Auth::SessionsController < Devise::SessionsController
end
def user_params
params.require(:user).permit(:email, :password, :otp_attempt, credential: {})
params.expect(user: [:email, :password, :otp_attempt, credential: {}])
end
def after_sign_in_path_for(resource)

@ -35,6 +35,6 @@ class Auth::SetupController < ApplicationController
end
def user_params
params.require(:user).permit(:email)
params.expect(user: [:email])
end
end

@ -24,6 +24,6 @@ module Admin::ExportControllerConcern
end
def import_params
params.require(:admin_import).permit(:data)
params.expect(admin_import: [:data])
end
end

@ -58,6 +58,6 @@ module ChallengableConcern
end
def challenge_params
params.require(:form_challenge).permit(:current_password, :return_to)
params.expect(form_challenge: [:current_password, :return_to])
end
end

@ -117,7 +117,7 @@ module SignatureVerification
def verify_signature_strength!
raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest')
raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(HttpSignatureDraft::REQUEST_TARGET) || signed_headers.include?('digest')
raise SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host')
raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
end
@ -155,14 +155,14 @@ module SignatureVerification
def build_signed_string(include_query_string: true)
signed_headers.map do |signed_header|
case signed_header
when Request::REQUEST_TARGET
when HttpSignatureDraft::REQUEST_TARGET
if include_query_string
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}"
"#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}"
else
# Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header.
# Therefore, temporarily support such incorrect signatures for compatibility.
# TODO: remove eventually some time after release of the fixed version
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
"#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
end
when '(created)'
raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'

@ -21,6 +21,6 @@ class Disputes::AppealsController < Disputes::BaseController
end
def appeal_params
params.require(:appeal).permit(:text)
params.expect(appeal: [:text])
end
end

@ -8,11 +8,4 @@ class Disputes::BaseController < ApplicationController
skip_before_action :require_functional!
before_action :authenticate_user!
before_action :set_cache_headers
private
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end
end

@ -6,7 +6,6 @@ class Filters::StatusesController < ApplicationController
before_action :authenticate_user!
before_action :set_filter
before_action :set_status_filters
before_action :set_cache_headers
PER_PAGE = 20
@ -34,14 +33,10 @@ class Filters::StatusesController < ApplicationController
end
def status_filter_batch_action_params
params.require(:form_status_filter_batch_action).permit(status_filter_ids: [])
params.expect(form_status_filter_batch_action: [status_filter_ids: []])
end
def action_from_button
'remove' if params[:remove]
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end
end

@ -5,7 +5,6 @@ class FiltersController < ApplicationController
before_action :authenticate_user!
before_action :set_filter, only: [:edit, :update, :destroy]
before_action :set_cache_headers
def index
@filters = current_account.custom_filters.includes(:keywords, :statuses).order(:phrase)
@ -48,10 +47,6 @@ class FiltersController < ApplicationController
end
def resource_params
params.require(:custom_filter).permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy])
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
params.expect(custom_filter: [:title, :expires_in, :filter_action, context: [], keywords_attributes: [[:id, :keyword, :whole_word, :_destroy]]])
end
end

@ -6,7 +6,6 @@ class InvitesController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_cache_headers
def index
authorize :invite, :create?
@ -43,10 +42,6 @@ class InvitesController < ApplicationController
end
def resource_params
params.require(:invite).permit(:max_uses, :expires_in, :autofollow, :comment)
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
params.expect(invite: [:max_uses, :expires_in, :autofollow, :comment])
end
end

@ -5,7 +5,6 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
before_action :store_current_location
before_action :authenticate_resource_owner!
before_action :set_cache_headers
content_security_policy do |p|
p.form_action(false)
@ -32,8 +31,4 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
def truthy_param?(key)
ActiveModel::Type::Boolean.new.cast(params[key])
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end
end

@ -6,7 +6,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
before_action :store_current_location
before_action :authenticate_resource_owner!
before_action :require_not_suspended!, only: :destroy
before_action :set_cache_headers
before_action :set_last_used_at_by_app, only: :index, unless: -> { request.format == :json }
@ -30,10 +29,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
forbidden if current_account.unavailable?
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end
def set_last_used_at_by_app
@last_used_at_by_app = current_resource_owner.applications_last_used
end

@ -6,7 +6,6 @@ class RelationshipsController < ApplicationController
before_action :authenticate_user!
before_action :set_accounts, only: :show
before_action :set_relationships, only: :show
before_action :set_cache_headers
helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship?
@ -36,7 +35,7 @@ class RelationshipsController < ApplicationController
end
def form_account_batch_params
params.require(:form_account_batch).permit(:action, account_ids: [])
params.expect(form_account_batch: [:action, account_ids: []])
end
def following_relationship?
@ -66,8 +65,4 @@ class RelationshipsController < ApplicationController
'remove_domains_from_followers'
end
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end
end

@ -30,7 +30,7 @@ class Settings::AliasesController < Settings::BaseController
private
def resource_params
params.require(:account_alias).permit(:acct)
params.expect(account_alias: [:acct])
end
def set_alias

@ -2,7 +2,6 @@
class Settings::ApplicationsController < Settings::BaseController
before_action :set_application, only: [:show, :update, :destroy, :regenerate]
before_action :prepare_scopes, only: [:create, :update]
def index
@applications = current_user.applications.order(id: :desc).page(params[:page])
@ -60,16 +59,6 @@ class Settings::ApplicationsController < Settings::BaseController
end
def application_params
params.require(:doorkeeper_application).permit(
:name,
:redirect_uri,
:scopes,
:website
)
end
def prepare_scopes
scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil)
params[:doorkeeper_application][:scopes] = scopes.join(' ') if scopes.is_a? Array
params.expect(doorkeeper_application: [:name, :redirect_uri, :website, scopes: []])
end
end

@ -4,14 +4,9 @@ class Settings::BaseController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_cache_headers
private
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end
def require_not_suspended!
forbidden if current_account.unavailable?
end

@ -21,7 +21,7 @@ class Settings::DeletesController < Settings::BaseController
private
def resource_params
params.require(:form_delete_confirmation).permit(:password, :username)
params.expect(form_delete_confirmation: [:password, :username])
end
def require_not_suspended!

@ -44,6 +44,6 @@ class Settings::FeaturedTagsController < Settings::BaseController
end
def featured_tag_params
params.require(:featured_tag).permit(:name)
params.expect(featured_tag: [:name])
end
end

@ -90,7 +90,7 @@ class Settings::ImportsController < Settings::BaseController
private
def import_params
params.require(:form_import).permit(:data, :type, :mode)
params.expect(form_import: [:data, :type, :mode])
end
def set_bulk_import

@ -33,6 +33,6 @@ class Settings::Migration::RedirectsController < Settings::BaseController
private
def resource_params
params.require(:form_redirect).permit(:acct, :current_password, :current_username)
params.expect(form_redirect: [:acct, :current_password, :current_username])
end
end

@ -27,7 +27,7 @@ class Settings::MigrationsController < Settings::BaseController
private
def resource_params
params.require(:account_migration).permit(:acct, :current_password, :current_username)
params.expect(account_migration: [:acct, :current_password, :current_username])
end
def set_migrations

@ -19,6 +19,6 @@ class Settings::Preferences::BaseController < Settings::BaseController
end
def user_params
params.require(:user).permit(:locale, :time_zone, chosen_languages: [], settings_attributes: UserSettings.keys)
params.expect(user: [:locale, :time_zone, chosen_languages: [], settings_attributes: UserSettings.keys])
end
end

@ -18,7 +18,7 @@ class Settings::PrivacyController < Settings::BaseController
private
def account_params
params.require(:account).permit(:discoverable, :unlocked, :indexable, :show_collections, settings: UserSettings.keys)
params.expect(account: [:discoverable, :unlocked, :indexable, :show_collections, settings: UserSettings.keys])
end
def set_account

@ -20,7 +20,7 @@ class Settings::ProfilesController < Settings::BaseController
private
def account_params
params.require(:account).permit(:display_name, :note, :avatar, :header, :bot, fields_attributes: [:name, :value])
params.expect(account: [:display_name, :note, :avatar, :header, :bot, fields_attributes: [[:name, :value]]])
end
def set_account

@ -38,7 +38,7 @@ module Settings
private
def confirmation_params
params.require(:form_two_factor_confirmation).permit(:otp_attempt)
params.expect(form_two_factor_confirmation: [:otp_attempt])
end
def prepare_two_factor_form

@ -18,7 +18,7 @@ class Settings::VerificationsController < Settings::BaseController
private
def account_params
params.require(:account).permit(:attribution_domains).tap do |params|
params.expect(account: [:attribution_domains]).tap do |params|
params[:attribution_domains] = params[:attribution_domains].split if params[:attribution_domains]
end
end

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

@ -5,7 +5,6 @@ class StatusesCleanupController < ApplicationController
before_action :authenticate_user!
before_action :set_policy
before_action :set_cache_headers
def show; end
@ -15,8 +14,6 @@ class StatusesCleanupController < ApplicationController
else
render :show
end
rescue ActionController::ParameterMissing
# Do nothing
end
def require_functional!
@ -30,10 +27,6 @@ class StatusesCleanupController < ApplicationController
end
def resource_params
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
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
params.expect(account_statuses_cleanup_policy: [:enabled, :min_status_age, :keep_direct, :keep_pinned, :keep_polls, :keep_media, :keep_self_fav, :keep_self_bookmark, :min_favs, :min_reblogs])
end
end

@ -0,0 +1,55 @@
import { useRef, useCallback } from 'react';
type Position = [number, number];
export const useSelectableClick = (
onClick: React.MouseEventHandler,
maxDelta = 5,
) => {
const clickPositionRef = useRef<Position | null>(null);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
clickPositionRef.current = [e.clientX, e.clientY];
}, []);
const handleMouseUp = useCallback(
(e: React.MouseEvent) => {
if (!clickPositionRef.current) {
return;
}
const [startX, startY] = clickPositionRef.current;
const [deltaX, deltaY] = [
Math.abs(e.clientX - startX),
Math.abs(e.clientY - startY),
];
let element: EventTarget | null = e.target;
while (element && element instanceof HTMLElement) {
if (
element.localName === 'button' ||
element.localName === 'a' ||
element.localName === 'label'
) {
return;
}
element = element.parentNode;
}
if (
deltaX + deltaY < maxDelta &&
(e.button === 0 || e.button === 1) &&
e.detail >= 1
) {
onClick(e);
}
clickPositionRef.current = null;
},
[maxDelta, onClick],
);
return [handleMouseDown, handleMouseUp] as const;
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

@ -414,7 +414,7 @@ export function initMediaEditModal(id) {
dispatch(openModal({
modalType: 'FOCAL_POINT',
modalProps: { id },
modalProps: { mediaId: id },
}));
};
}

@ -0,0 +1,70 @@
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { apiUpdateMedia } from 'mastodon/api/compose';
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
unattached?: boolean;
};
const simulateModifiedApiResponse = (
media: MediaAttachment,
params: { description?: string; focus?: string },
): SimulatedMediaAttachmentJSON => {
const [x, y] = (params.focus ?? '').split(',');
const data = {
...media.toJS(),
...params,
meta: {
focus: {
x: parseFloat(x ?? '0'),
y: parseFloat(y ?? '0'),
},
},
} as unknown as SimulatedMediaAttachmentJSON;
return data;
};
export const changeUploadCompose = createDataLoadingThunk(
'compose/changeUpload',
async (
{
id,
...params
}: {
id: string;
description: string;
focus: string;
},
{ getState },
) => {
const media = (
(getState().compose as ImmutableMap<string, unknown>).get(
'media_attachments',
) as ImmutableList<MediaAttachment>
).find((item) => item.get('id') === id);
// Editing already-attached media is deferred to editing the post itself.
// For simplicity's sake, fake an API reply.
if (media && !media.get('unattached')) {
return new Promise<SimulatedMediaAttachmentJSON>((resolve) => {
resolve(simulateModifiedApiResponse(media, params));
});
}
return apiUpdateMedia(id, params);
},
(media: SimulatedMediaAttachmentJSON) => {
return {
media,
attached: typeof media.unattached !== 'undefined' && !media.unattached,
};
},
{
useLoadingBar: false,
},
);

@ -9,6 +9,7 @@ export type ModalType = keyof typeof MODAL_COMPONENTS;
interface OpenModalPayload {
modalType: ModalType;
modalProps: ModalProps;
previousModalProps?: ModalProps;
}
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');

@ -0,0 +1,7 @@
import { apiRequestPut } from 'mastodon/api';
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
export const apiUpdateMedia = (
id: string,
params?: { description?: string; focus?: string },
) => apiRequestPut<ApiMediaAttachmentJSON>(`v1/media/${id}`, params);

@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useId } from 'react';
import { FormattedMessage } from 'react-intl';
@ -8,12 +8,15 @@ import type {
UsePopperOptions,
} from 'react-overlays/esm/usePopper';
import { useSelectableClick } from '@/hooks/useSelectableClick';
const offset = [0, 4] as OffsetValue;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
export const AltTextBadge: React.FC<{
description: string;
}> = ({ description }) => {
const accessibilityId = useId();
const anchorRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
@ -25,12 +28,16 @@ export const AltTextBadge: React.FC<{
setOpen(false);
}, [setOpen]);
const [handleMouseDown, handleMouseUp] = useSelectableClick(handleClose);
return (
<>
<button
ref={anchorRef}
className='media-gallery__alt__label'
onClick={handleClick}
aria-expanded={open}
aria-controls={accessibilityId}
>
ALT
</button>
@ -47,9 +54,12 @@ export const AltTextBadge: React.FC<{
>
{({ props }) => (
<div {...props} className='hover-card-controller'>
<div
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
className='media-gallery__alt__popover dropdown-animation'
role='tooltip'
role='region'
id={accessibilityId}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
<h4>
<FormattedMessage

@ -7,6 +7,7 @@ interface BaseProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
block?: boolean;
secondary?: boolean;
compact?: boolean;
dangerous?: boolean;
}
@ -27,6 +28,7 @@ export const Button: React.FC<Props> = ({
disabled,
block,
secondary,
compact,
dangerous,
className,
title,
@ -47,6 +49,7 @@ export const Button: React.FC<Props> = ({
<button
className={classNames('button', className, {
'button-secondary': secondary,
'button--compact': compact,
'button--block': block,
'button--dangerous': dangerous,
})}

@ -2,6 +2,8 @@ import { useCallback, useEffect } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { useIdentity } from '@/mastodon/identity_context';
import { fetchRelationships, followAccount } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
@ -20,7 +22,8 @@ const messages = defineMessages({
export const FollowButton: React.FC<{
accountId?: string;
}> = ({ accountId }) => {
compact?: boolean;
}> = ({ accountId, compact }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { signedIn } = useIdentity();
@ -89,7 +92,9 @@ export const FollowButton: React.FC<{
href='/settings/profile'
target='_blank'
rel='noopener'
className='button button-secondary'
className={classNames('button button-secondary', {
'button--compact': compact,
})}
>
{label}
</a>
@ -106,6 +111,7 @@ export const FollowButton: React.FC<{
(account?.suspended || !!account?.moved))
}
secondary={following}
compact={compact}
className={following ? 'button--destructive' : undefined}
>
{label}

@ -1,70 +1,70 @@
import { useCallback, useState } from 'react';
import { useCallback, useState, forwardRef } from 'react';
interface Props {
src: string;
key: string;
alt?: string;
lang?: string;
width: number;
height: number;
onClick?: () => void;
width?: number;
height?: number;
onClick?: React.MouseEventHandler;
onMouseDown?: React.MouseEventHandler;
onTouchStart?: React.TouchEventHandler;
}
export const GIFV: React.FC<Props> = ({
src,
alt,
lang,
width,
height,
onClick,
}) => {
const [loading, setLoading] = useState(true);
export const GIFV = forwardRef<HTMLVideoElement, Props>(
(
{ src, alt, lang, width, height, onClick, onMouseDown, onTouchStart },
ref,
) => {
const [loading, setLoading] = useState(true);
const handleLoadedData: React.ReactEventHandler<HTMLVideoElement> =
useCallback(() => {
const handleLoadedData = useCallback(() => {
setLoading(false);
}, [setLoading]);
const handleClick: React.MouseEventHandler = useCallback(
(e) => {
if (onClick) {
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onClick();
}
},
[onClick],
);
onClick?.(e);
},
[onClick],
);
return (
<div className='gifv' style={{ position: 'relative' }}>
{loading && (
<canvas
width={width}
height={height}
return (
<div className='gifv'>
{loading && (
<canvas
role='button'
tabIndex={0}
aria-label={alt}
title={alt}
lang={lang}
onClick={handleClick}
/>
)}
<video
ref={ref}
src={src}
role='button'
tabIndex={0}
aria-label={alt}
title={alt}
lang={lang}
width={width}
height={height}
muted
loop
autoPlay
playsInline
onClick={handleClick}
onLoadedData={handleLoadedData}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
/>
)}
</div>
);
},
);
<video
src={src}
role='button'
tabIndex={0}
aria-label={alt}
title={alt}
lang={lang}
muted
loop
autoPlay
playsInline
onClick={handleClick}
onLoadedData={handleLoadedData}
style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }}
/>
</div>
);
};
GIFV.displayName = 'GIFV';

@ -1,9 +1,10 @@
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
const messages = defineMessages({
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
@ -17,10 +18,12 @@ interface Props<T> {
export const LoadGap = <T,>({ disabled, param, onClick }: Props<T>) => {
const intl = useIntl();
const [loading, setLoading] = useState(false);
const handleClick = useCallback(() => {
setLoading(true);
onClick(param);
}, [param, onClick]);
}, [setLoading, param, onClick]);
return (
<button
@ -28,8 +31,13 @@ export const LoadGap = <T,>({ disabled, param, onClick }: Props<T>) => {
disabled={disabled}
onClick={handleClick}
aria-label={intl.formatMessage(messages.load_more)}
title={intl.formatMessage(messages.load_more)}
>
<Icon id='ellipsis-h' icon={MoreHorizIcon} />
{loading ? (
<LoadingIndicator />
) : (
<Icon id='ellipsis-h' icon={MoreHorizIcon} />
)}
</button>
);
};

@ -145,7 +145,6 @@ class Item extends PureComponent {
srcSet={srcSet}
sizes={sizes}
alt={description}
title={description}
lang={lang}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
@ -167,7 +166,6 @@ class Item extends PureComponent {
<video
className='media-gallery__item-gifv-thumbnail'
aria-label={description}
title={description}
lang={lang}
role='application'
src={attachment.get('url')}

@ -118,9 +118,9 @@ class ModalRoot extends PureComponent {
}
_ensureHistoryBuffer () {
const { pathname, state } = this.history.location;
const { pathname, search, hash, state } = this.history.location;
if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
this.history.push(pathname, { ...state, mastodonModalKey: this._modalHistoryKey });
this.history.push({ pathname, search, hash }, { ...state, mastodonModalKey: this._modalHistoryKey });
}
}

@ -333,7 +333,7 @@ class Status extends ImmutablePureComponent {
const { onToggleHidden } = this.props;
const status = this._properStatus();
if (status.get('matched_filters')) {
if (this.props.status.get('matched_filters')) {
const expandedBecauseOfCW = !status.get('hidden') || status.get('spoiler_text').length === 0;
const expandedBecauseOfFilter = this.state.showDespiteFilter;
@ -527,7 +527,7 @@ class Status extends ImmutablePureComponent {
</Bundle>
);
}
} else if (status.get('spoiler_text').length === 0 && status.get('card')) {
} else if (status.get('card')) {
media = (
<Card
onOpenMedia={this.handleOpenMedia}

@ -1,86 +0,0 @@
import PropTypes from 'prop-types';
import { useState, useRef, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import BadgeIcon from '@/material-icons/400-24px/badge.svg?react';
import GlobeIcon from '@/material-icons/400-24px/globe.svg?react';
import { Icon } from 'mastodon/components/icon';
export const DomainPill = ({ domain, username, isSelf }) => {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const triggerRef = useRef(null);
const handleClick = useCallback(() => {
setOpen(!open);
}, [open, setOpen]);
const handleExpandClick = useCallback(() => {
setExpanded(!expanded);
}, [expanded, setExpanded]);
return (
<>
<button className={classNames('account__domain-pill', { active: open })} ref={triggerRef} onClick={handleClick}>{domain}</button>
<Overlay show={open} rootClose onHide={handleClick} offset={[5, 5]} target={triggerRef}>
{({ props }) => (
<div {...props} className='account__domain-pill__popout dropdown-animation'>
<div className='account__domain-pill__popout__header'>
<div className='account__domain-pill__popout__header__icon'><Icon icon={BadgeIcon} /></div>
<h3><FormattedMessage id='domain_pill.whats_in_a_handle' defaultMessage="What's in a handle?" /></h3>
</div>
<div className='account__domain-pill__popout__handle'>
<div className='account__domain-pill__popout__handle__label'>{isSelf ? <FormattedMessage id='domain_pill.your_handle' defaultMessage='Your handle:' /> : <FormattedMessage id='domain_pill.their_handle' defaultMessage='Their handle:' />}</div>
<div className='account__domain-pill__popout__handle__handle'>@{username}@{domain}</div>
</div>
<div className='account__domain-pill__popout__parts'>
<div>
<div className='account__domain-pill__popout__parts__icon'><Icon icon={AlternateEmailIcon} /></div>
<div>
<h6><FormattedMessage id='domain_pill.username' defaultMessage='Username' /></h6>
<p>{isSelf ? <FormattedMessage id='domain_pill.your_username' defaultMessage='Your unique identifier on this server. Its possible to find users with the same username on different servers.' /> : <FormattedMessage id='domain_pill.their_username' defaultMessage='Their unique identifier on their server. Its possible to find users with the same username on different servers.' />}</p>
</div>
</div>
<div>
<div className='account__domain-pill__popout__parts__icon'><Icon icon={GlobeIcon} /></div>
<div>
<h6><FormattedMessage id='domain_pill.server' defaultMessage='Server' /></h6>
<p>{isSelf ? <FormattedMessage id='domain_pill.your_server' defaultMessage='Your digital home, where all of your posts live. Dont like this one? Transfer servers at any time and bring your followers, too.' /> : <FormattedMessage id='domain_pill.their_server' defaultMessage='Their digital home, where all of their posts live.' />}</p>
</div>
</div>
</div>
<p>{isSelf ? <FormattedMessage id='domain_pill.who_you_are' defaultMessage='Because your handle says who you are and where you are, people can interact with you across the social web of <button>ActivityPub-powered platforms</button>.' values={{ button: x => <button onClick={handleExpandClick} className='link-button'>{x}</button> }} /> : <FormattedMessage id='domain_pill.who_they_are' defaultMessage='Since handles say who someone is and where they are, you can interact with people across the social web of <button>ActivityPub-powered platforms</button>.' values={{ button: x => <button onClick={handleExpandClick} className='link-button'>{x}</button> }} />}</p>
{expanded && (
<>
<p><FormattedMessage id='domain_pill.activitypub_like_language' defaultMessage='ActivityPub is like the language Mastodon speaks with other social networks.' /></p>
<p><FormattedMessage id='domain_pill.activitypub_lets_connect' defaultMessage='It lets you connect and interact with people not just on Mastodon, but across different social apps too.' /></p>
</>
)}
</div>
)}
</Overlay>
</>
);
};
DomainPill.propTypes = {
username: PropTypes.string.isRequired,
domain: PropTypes.string.isRequired,
isSelf: PropTypes.bool,
};

@ -0,0 +1,202 @@
import { useState, useRef, useCallback, useId } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import BadgeIcon from '@/material-icons/400-24px/badge.svg?react';
import GlobeIcon from '@/material-icons/400-24px/globe.svg?react';
import { Icon } from 'mastodon/components/icon';
export const DomainPill: React.FC<{
domain: string;
username: string;
isSelf: boolean;
}> = ({ domain, username, isSelf }) => {
const accessibilityId = useId();
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const triggerRef = useRef(null);
const handleClick = useCallback(() => {
setOpen(!open);
}, [open, setOpen]);
const handleExpandClick = useCallback(() => {
setExpanded(!expanded);
}, [expanded, setExpanded]);
return (
<>
<button
className={classNames('account__domain-pill', { active: open })}
ref={triggerRef}
onClick={handleClick}
aria-expanded={open}
aria-controls={accessibilityId}
>
{domain}
</button>
<Overlay
show={open}
rootClose
onHide={handleClick}
offset={[5, 5]}
target={triggerRef}
>
{({ props }) => (
<div
{...props}
role='region'
id={accessibilityId}
className='account__domain-pill__popout dropdown-animation'
>
<div className='account__domain-pill__popout__header'>
<div className='account__domain-pill__popout__header__icon'>
<Icon id='' icon={BadgeIcon} />
</div>
<h3>
<FormattedMessage
id='domain_pill.whats_in_a_handle'
defaultMessage="What's in a handle?"
/>
</h3>
</div>
<div className='account__domain-pill__popout__handle'>
<div className='account__domain-pill__popout__handle__label'>
{isSelf ? (
<FormattedMessage
id='domain_pill.your_handle'
defaultMessage='Your handle:'
/>
) : (
<FormattedMessage
id='domain_pill.their_handle'
defaultMessage='Their handle:'
/>
)}
</div>
<div className='account__domain-pill__popout__handle__handle'>
@{username}@{domain}
</div>
</div>
<div className='account__domain-pill__popout__parts'>
<div>
<div className='account__domain-pill__popout__parts__icon'>
<Icon id='' icon={AlternateEmailIcon} />
</div>
<div>
<h6>
<FormattedMessage
id='domain_pill.username'
defaultMessage='Username'
/>
</h6>
<p>
{isSelf ? (
<FormattedMessage
id='domain_pill.your_username'
defaultMessage='Your unique identifier on this server. Its possible to find users with the same username on different servers.'
/>
) : (
<FormattedMessage
id='domain_pill.their_username'
defaultMessage='Their unique identifier on their server. Its possible to find users with the same username on different servers.'
/>
)}
</p>
</div>
</div>
<div>
<div className='account__domain-pill__popout__parts__icon'>
<Icon id='' icon={GlobeIcon} />
</div>
<div>
<h6>
<FormattedMessage
id='domain_pill.server'
defaultMessage='Server'
/>
</h6>
<p>
{isSelf ? (
<FormattedMessage
id='domain_pill.your_server'
defaultMessage='Your digital home, where all of your posts live. Dont like this one? Transfer servers at any time and bring your followers, too.'
/>
) : (
<FormattedMessage
id='domain_pill.their_server'
defaultMessage='Their digital home, where all of their posts live.'
/>
)}
</p>
</div>
</div>
</div>
<p>
{isSelf ? (
<FormattedMessage
id='domain_pill.who_you_are'
defaultMessage='Because your handle says who you are and where you are, people can interact with you across the social web of <button>ActivityPub-powered platforms</button>.'
values={{
button: (x) => (
<button
onClick={handleExpandClick}
className='link-button'
>
{x}
</button>
),
}}
/>
) : (
<FormattedMessage
id='domain_pill.who_they_are'
defaultMessage='Since handles say who someone is and where they are, you can interact with people across the social web of <button>ActivityPub-powered platforms</button>.'
values={{
button: (x) => (
<button
onClick={handleExpandClick}
className='link-button'
>
{x}
</button>
),
}}
/>
)}
</p>
{expanded && (
<>
<p>
<FormattedMessage
id='domain_pill.activitypub_like_language'
defaultMessage='ActivityPub is like the language Mastodon speaks with other social networks.'
/>
</p>
<p>
<FormattedMessage
id='domain_pill.activitypub_lets_connect'
defaultMessage='It lets you connect and interact with people not just on Mastodon, but across different social apps too.'
/>
</p>
</>
)}
</div>
)}
</Overlay>
</>
);
};

@ -93,7 +93,6 @@ export const MediaItem: React.FC<{
<img
src={previewUrl || avatarUrl}
alt={description}
title={description}
lang={lang}
onLoad={handleImageLoad}
/>
@ -113,7 +112,6 @@ export const MediaItem: React.FC<{
<img
src={previewUrl}
alt={description}
title={description}
lang={lang}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={handleImageLoad}
@ -131,7 +129,6 @@ export const MediaItem: React.FC<{
<video
className='media-gallery__item-gifv-thumbnail'
aria-label={description}
title={description}
lang={lang}
src={fullUrl}
onMouseEnter={handleMouseEnter}

@ -0,0 +1,87 @@
import { useState, useRef, useCallback, useId } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import { useSelectableClick } from '@/hooks/useSelectableClick';
import QuestionMarkIcon from '@/material-icons/400-24px/question_mark.svg?react';
import { Icon } from 'mastodon/components/icon';
const messages = defineMessages({
help: { id: 'info_button.label', defaultMessage: 'Help' },
});
export const InfoButton: React.FC = () => {
const intl = useIntl();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const accessibilityId = useId();
const handleClick = useCallback(() => {
setOpen(!open);
}, [open, setOpen]);
const [handleMouseDown, handleMouseUp] = useSelectableClick(handleClick);
return (
<>
<button
type='button'
className={classNames('help-button', { active: open })}
ref={triggerRef}
onClick={handleClick}
aria-expanded={open}
aria-controls={accessibilityId}
aria-label={intl.formatMessage(messages.help)}
>
<Icon id='' icon={QuestionMarkIcon} />
</button>
<Overlay
show={open}
rootClose
placement='top'
onHide={handleClick}
offset={[5, 5]}
target={triggerRef}
>
{({ props }) => (
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
{...props}
className='dialog-modal__popout prose dropdown-animation'
role='region'
id={accessibilityId}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
<FormattedMessage
id='info_button.what_is_alt_text'
defaultMessage='<h1>What is alt text?</h1>
<p>Alt text provides image descriptions for people with vision impairments, low-bandwidth connections, or those seeking extra context.</p>
<p>You can improve accessibility and understanding for everyone by writing clear, concise, and objective alt text.</p>
<ul>
<li>Capture important elements</li>
<li>Summarize text in images</li>
<li>Use regular sentence structure</li>
<li>Avoid redundant information</li>
<li>Focus on trends and key findings in complex visuals (like diagrams or maps)</li>
</ul>'
values={{
h1: (node) => <h1>{node}</h1>,
p: (node) => <p>{node}</p>,
ul: (node) => <ul>{node}</ul>,
li: (node) => <li>{node}</li>,
}}
/>
</div>
)}
</Overlay>
</>
);
};

@ -0,0 +1,537 @@
import {
useState,
useCallback,
useRef,
useEffect,
useImperativeHandle,
forwardRef,
} from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import Textarea from 'react-textarea-autosize';
import { length } from 'stringz';
// eslint-disable-next-line import/extensions
import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
// eslint-disable-next-line import/no-extraneous-dependencies
import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
import { showAlertForError } from 'mastodon/actions/alerts';
import { uploadThumbnail } from 'mastodon/actions/compose';
import { changeUploadCompose } from 'mastodon/actions/compose_typed';
import { Button } from 'mastodon/components/button';
import { GIFV } from 'mastodon/components/gifv';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { Skeleton } from 'mastodon/components/skeleton';
import Audio from 'mastodon/features/audio';
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
import Video, { getPointerPosition } from 'mastodon/features/video';
import { me } from 'mastodon/initial_state';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { assetHost } from 'mastodon/utils/config';
import { InfoButton } from './components/info_button';
const messages = defineMessages({
placeholderVisual: {
id: 'alt_text_modal.describe_for_people_with_visual_impairments',
defaultMessage: 'Describe this for people with visual impairments…',
},
placeholderHearing: {
id: 'alt_text_modal.describe_for_people_with_hearing_impairments',
defaultMessage: 'Describe this for people with hearing impairments…',
},
discardMessage: {
id: 'confirmations.discard_edit_media.message',
defaultMessage:
'You have unsaved changes to the media description or preview, discard them anyway?',
},
discardConfirm: {
id: 'confirmations.discard_edit_media.confirm',
defaultMessage: 'Discard',
},
});
const MAX_LENGTH = 1500;
type FocalPoint = [number, number];
const UploadButton: React.FC<{
children: React.ReactNode;
onSelectFile: (arg0: File) => void;
mimeTypes: string;
}> = ({ children, onSelectFile, mimeTypes }) => {
const fileRef = useRef<HTMLInputElement>(null);
const handleClick = useCallback(() => {
fileRef.current?.click();
}, []);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onSelectFile(file);
}
},
[onSelectFile],
);
return (
<label>
<Button onClick={handleClick}>{children}</Button>
<input
id='upload-modal__thumbnail'
ref={fileRef}
type='file'
accept={mimeTypes}
onChange={handleChange}
style={{ display: 'none' }}
/>
</label>
);
};
const Preview: React.FC<{
mediaId: string;
position: FocalPoint;
onPositionChange: (arg0: FocalPoint) => void;
}> = ({ mediaId, position, onPositionChange }) => {
const media = useAppSelector((state) =>
(
(state.compose as ImmutableMap<string, unknown>).get(
'media_attachments',
) as ImmutableList<MediaAttachment>
).find((x) => x.get('id') === mediaId),
);
const account = useAppSelector((state) =>
me ? state.accounts.get(me) : undefined,
);
const [dragging, setDragging] = useState(false);
const [x, y] = position;
const nodeRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null);
const draggingRef = useRef<boolean>(false);
const setRef = useCallback(
(e: HTMLImageElement | HTMLVideoElement | null) => {
nodeRef.current = e;
},
[],
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0) {
return;
}
const { x, y } = getPointerPosition(nodeRef.current, e);
setDragging(true);
draggingRef.current = true;
onPositionChange([x, y]);
},
[setDragging, onPositionChange],
);
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
const { x, y } = getPointerPosition(nodeRef.current, e);
setDragging(true);
draggingRef.current = true;
onPositionChange([x, y]);
},
[setDragging, onPositionChange],
);
useEffect(() => {
const handleMouseUp = () => {
setDragging(false);
draggingRef.current = false;
};
const handleMouseMove = (e: MouseEvent) => {
if (draggingRef.current) {
const { x, y } = getPointerPosition(nodeRef.current, e);
onPositionChange([x, y]);
}
};
const handleTouchEnd = () => {
setDragging(false);
draggingRef.current = false;
};
const handleTouchMove = (e: TouchEvent) => {
if (draggingRef.current) {
const { x, y } = getPointerPosition(nodeRef.current, e);
onPositionChange([x, y]);
}
};
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchmove', handleTouchMove);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchmove', handleTouchMove);
};
}, [setDragging, onPositionChange]);
if (!media) {
return null;
}
if (media.get('type') === 'image') {
return (
<div className={classNames('focal-point', { dragging })}>
<img
ref={setRef}
draggable={false}
src={media.get('url') as string}
alt=''
role='presentation'
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
/>
<div
className='focal-point__reticle'
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
/>
</div>
);
} else if (media.get('type') === 'gifv') {
return (
<div className={classNames('focal-point', { dragging })}>
<GIFV
ref={setRef}
src={media.get('url') as string}
alt=''
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
/>
<div
className='focal-point__reticle'
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
/>
</div>
);
} else if (media.get('type') === 'video') {
return (
<Video
preview={media.get('preview_url') as string}
frameRate={media.getIn(['meta', 'original', 'frame_rate']) as string}
blurhash={media.get('blurhash') as string}
src={media.get('url') as string}
detailed
inline
editable
/>
);
} else if (media.get('type') === 'audio') {
return (
<Audio
src={media.get('url') as string}
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
poster={
(media.get('preview_url') as string | undefined) ??
account?.avatar_static
}
backgroundColor={
media.getIn(['meta', 'colors', 'background']) as string
}
foregroundColor={
media.getIn(['meta', 'colors', 'foreground']) as string
}
accentColor={media.getIn(['meta', 'colors', 'accent']) as string}
editable
/>
);
} else {
return null;
}
};
interface RestoreProps {
previousDescription: string;
previousPosition: FocalPoint;
}
interface Props {
mediaId: string;
onClose: () => void;
}
interface ConfirmationMessage {
message: string;
confirm: string;
props?: RestoreProps;
}
export interface ModalRef {
getCloseConfirmationMessage: () => null | ConfirmationMessage;
}
export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
({ mediaId, previousDescription, previousPosition, onClose }, ref) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const media = useAppSelector((state) =>
(
(state.compose as ImmutableMap<string, unknown>).get(
'media_attachments',
) as ImmutableList<MediaAttachment>
).find((x) => x.get('id') === mediaId),
);
const lang = useAppSelector(
(state) =>
(state.compose as ImmutableMap<string, unknown>).get('lang') as string,
);
const focusX =
(media?.getIn(['meta', 'focus', 'x'], 0) as number | undefined) ?? 0;
const focusY =
(media?.getIn(['meta', 'focus', 'y'], 0) as number | undefined) ?? 0;
const [description, setDescription] = useState(
previousDescription ??
(media?.get('description') as string | undefined) ??
'',
);
const [position, setPosition] = useState<FocalPoint>(
previousPosition ?? [focusX / 2 + 0.5, focusY / -2 + 0.5],
);
const [isDetecting, setIsDetecting] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const dirtyRef = useRef(
previousDescription || previousPosition ? true : false,
);
const type = media?.get('type') as string;
const valid = length(description) <= MAX_LENGTH;
const handleDescriptionChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setDescription(e.target.value);
dirtyRef.current = true;
},
[setDescription],
);
const handleThumbnailChange = useCallback(
(file: File) => {
dispatch(uploadThumbnail(mediaId, file));
},
[dispatch, mediaId],
);
const handlePositionChange = useCallback(
(position: FocalPoint) => {
setPosition(position);
dirtyRef.current = true;
},
[setPosition],
);
const handleSubmit = useCallback(() => {
setIsSaving(true);
dispatch(
changeUploadCompose({
id: mediaId,
description,
focus: `${((position[0] - 0.5) * 2).toFixed(2)},${((position[1] - 0.5) * -2).toFixed(2)}`,
}),
)
.then(() => {
setIsSaving(false);
dirtyRef.current = false;
onClose();
return '';
})
.catch((err: unknown) => {
setIsSaving(false);
dispatch(showAlertForError(err));
});
}, [dispatch, setIsSaving, mediaId, onClose, position, description]);
const handleKeyUp = useCallback(
(e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
if (valid) {
handleSubmit();
}
}
},
[handleSubmit, valid],
);
const handleDetectClick = useCallback(() => {
setIsDetecting(true);
fetchTesseract()
.then(async ({ createWorker }) => {
const worker = await createWorker('eng', 1, {
workerPath: tesseractWorkerPath as string,
corePath: tesseractCorePath as string,
langPath: `${assetHost}/ocr/lang-data`,
cacheMethod: 'write',
});
const image = URL.createObjectURL(media?.get('file') as File);
const result = await worker.recognize(image);
setDescription(result.data.text);
setIsDetecting(false);
await worker.terminate();
return '';
})
.catch(() => {
setIsDetecting(false);
});
}, [setDescription, setIsDetecting, media]);
useImperativeHandle(
ref,
() => ({
getCloseConfirmationMessage: () => {
if (dirtyRef.current) {
return {
message: intl.formatMessage(messages.discardMessage),
confirm: intl.formatMessage(messages.discardConfirm),
props: {
previousDescription: description,
previousPosition: position,
},
};
}
return null;
},
}),
[intl, description, position],
);
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<Button onClick={handleSubmit} disabled={!valid}>
{isSaving ? (
<LoadingIndicator />
) : (
<FormattedMessage
id='alt_text_modal.done'
defaultMessage='Done'
/>
)}
</Button>
<span className='dialog-modal__header__title'>
<FormattedMessage
id='alt_text_modal.add_alt_text'
defaultMessage='Add alt text'
/>
</span>
<Button secondary onClick={onClose}>
<FormattedMessage
id='alt_text_modal.cancel'
defaultMessage='Cancel'
/>
</Button>
</div>
<div className='dialog-modal__content'>
<div className='dialog-modal__content__preview'>
<Preview
mediaId={mediaId}
position={position}
onPositionChange={handlePositionChange}
/>
{(type === 'audio' || type === 'video') && (
<UploadButton
onSelectFile={handleThumbnailChange}
mimeTypes='image/jpeg,image/png,image/gif,image/heic,image/heif,image/webp,image/avif'
>
<FormattedMessage
id='alt_text_modal.change_thumbnail'
defaultMessage='Change thumbnail'
/>
</UploadButton>
)}
</div>
<form
className='dialog-modal__content__form simple_form'
onSubmit={handleSubmit}
>
<div className='input'>
<div className='label_input'>
<Textarea
id='description'
value={isDetecting ? ' ' : description}
onChange={handleDescriptionChange}
onKeyUp={handleKeyUp}
lang={lang}
placeholder={intl.formatMessage(
type === 'audio'
? messages.placeholderHearing
: messages.placeholderVisual,
)}
minRows={3}
disabled={isDetecting}
/>
{isDetecting && (
<div className='label_input__loading-indicator'>
<Skeleton width='100%' />
<Skeleton width='100%' />
<Skeleton width='61%' />
</div>
)}
</div>
<div className='input__toolbar'>
<CharacterCounter
max={MAX_LENGTH}
text={isDetecting ? '' : description}
/>
<div className='spacer' />
<button
className='link-button'
onClick={handleDetectClick}
disabled={type !== 'image' || isDetecting}
>
<FormattedMessage
id='alt_text_modal.add_text_from_image'
defaultMessage='Add text from image'
/>
</button>
<InfoButton />
</div>
</div>
</form>
</div>
</div>
);
},
);
AltTextModal.displayName = 'AltTextModal';

@ -581,10 +581,14 @@ class Audio extends PureComponent {
</div>
<div className='video-player__buttons right'>
{!editable && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' icon={VisibilityOffIcon} /></button>}
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
<Icon id={'download'} icon={DownloadIcon} />
</a>
{!editable && (
<>
<button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' icon={VisibilityOffIcon} /></button>
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
<Icon id='download' icon={DownloadIcon} />
</a>
</>
)}
</div>
</div>
</div>

@ -1,18 +0,0 @@
import PropTypes from 'prop-types';
import { length } from 'stringz';
export const CharacterCounter = ({ text, max }) => {
const diff = max - length(text);
if (diff < 0) {
return <span className='character-counter character-counter--over'>{diff}</span>;
}
return <span className='character-counter'>{diff}</span>;
};
CharacterCounter.propTypes = {
text: PropTypes.string.isRequired,
max: PropTypes.number.isRequired,
};

@ -0,0 +1,16 @@
import { length } from 'stringz';
export const CharacterCounter: React.FC<{
text: string;
max: number;
}> = ({ text, max }) => {
const diff = max - length(text);
if (diff < 0) {
return (
<span className='character-counter character-counter--over'>{diff}</span>
);
}
return <span className='character-counter'>{diff}</span>;
};

@ -10,11 +10,12 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz';
import { missingAltTextModal } from 'mastodon/initial_state';
import AutosuggestInput from '../../../components/autosuggest_input';
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import { Button } from '../../../components/button';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import LanguageDropdown from '../containers/language_dropdown_container';
import PollButtonContainer from '../containers/poll_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
@ -24,6 +25,7 @@ import { countableText } from '../util/counter';
import { CharacterCounter } from './character_counter';
import { EditIndicator } from './edit_indicator';
import { LanguageDropdown } from './language_dropdown';
import { NavigationBar } from './navigation_bar';
import { PollForm } from "./poll_form";
import { ReplyIndicator } from './reply_indicator';
@ -65,6 +67,7 @@ class ComposeForm extends ImmutablePureComponent {
autoFocus: PropTypes.bool,
withoutNavigation: PropTypes.bool,
anyMedia: PropTypes.bool,
missingAltText: PropTypes.bool,
isInReply: PropTypes.bool,
singleColumn: PropTypes.bool,
lang: PropTypes.string,
@ -117,7 +120,7 @@ class ComposeForm extends ImmutablePureComponent {
return;
}
this.props.onSubmit();
this.props.onSubmit(missingAltTextModal && this.props.missingAltText);
if (e) {
e.preventDefault();
@ -301,6 +304,7 @@ class ComposeForm extends ImmutablePureComponent {
<div className='compose-form__submit'>
<Button
type='submit'
compact
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
disabled={!this.canSubmit()}
/>

@ -1,324 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { supportsPassiveEvents } from 'detect-passive-events';
import fuzzysort from 'fuzzysort';
import Overlay from 'react-overlays/Overlay';
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import TranslateIcon from '@/material-icons/400-24px/translate.svg?react';
import { Icon } from 'mastodon/components/icon';
import { languages as preloadedLanguages } from 'mastodon/initial_state';
const messages = defineMessages({
changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' },
search: { id: 'compose.language.search', defaultMessage: 'Search languages...' },
clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
});
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
class LanguageDropdownMenu extends PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
intl: PropTypes.object,
};
static defaultProps = {
languages: preloadedLanguages,
};
state = {
searchValue: '',
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
e.stopPropagation();
}
};
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
// Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
// to wait for a frame before focusing
requestAnimationFrame(() => {
if (this.node) {
const element = this.node.querySelector('input[type="search"]');
if (element) element.focus();
}
});
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
};
setListRef = c => {
this.listNode = c;
};
handleSearchChange = ({ target }) => {
this.setState({ searchValue: target.value });
};
search () {
const { languages, value, frequentlyUsedLanguages } = this.props;
const { searchValue } = this.state;
if (searchValue === '') {
return [...languages].sort((a, b) => {
// Push current selection to the top of the list
if (a[0] === value) {
return -1;
} else if (b[0] === value) {
return 1;
} else {
// Sort according to frequently used languages
const indexOfA = frequentlyUsedLanguages.indexOf(a[0]);
const indexOfB = frequentlyUsedLanguages.indexOf(b[0]);
return ((indexOfA > -1 ? indexOfA : Infinity) - (indexOfB > -1 ? indexOfB : Infinity));
}
});
}
return fuzzysort.go(searchValue, languages, {
keys: ['0', '1', '2'],
limit: 5,
threshold: -10000,
}).map(result => result.obj);
}
handleClick = e => {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onClose();
this.props.onChange(value);
};
handleKeyDown = e => {
const { onClose } = this.props;
const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
let element = null;
switch(e.key) {
case 'Escape':
onClose();
break;
case ' ':
case 'Enter':
this.handleClick(e);
break;
case 'ArrowDown':
element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
break;
case 'ArrowUp':
element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
break;
case 'Tab':
if (e.shiftKey) {
element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
} else {
element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
}
break;
case 'Home':
element = this.listNode.firstChild;
break;
case 'End':
element = this.listNode.lastChild;
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
};
handleSearchKeyDown = e => {
const { onChange, onClose } = this.props;
const { searchValue } = this.state;
let element = null;
switch(e.key) {
case 'Tab':
case 'ArrowDown':
element = this.listNode.firstChild;
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
break;
case 'Enter':
element = this.listNode.firstChild;
if (element) {
onChange(element.getAttribute('data-index'));
onClose();
}
break;
case 'Escape':
if (searchValue !== '') {
e.preventDefault();
this.handleClear();
}
break;
}
};
handleClear = () => {
this.setState({ searchValue: '' });
};
renderItem = lang => {
const { value } = this.props;
return (
<div key={lang[0]} role='option' tabIndex={0} data-index={lang[0]} className={classNames('language-dropdown__dropdown__results__item', { active: lang[0] === value })} aria-selected={lang[0] === value} onClick={this.handleClick} onKeyDown={this.handleKeyDown}>
<span className='language-dropdown__dropdown__results__item__native-name' lang={lang[0]}>{lang[2]}</span> <span className='language-dropdown__dropdown__results__item__common-name'>({lang[1]})</span>
</div>
);
};
render () {
const { intl } = this.props;
const { searchValue } = this.state;
const isSearching = searchValue !== '';
const results = this.search();
return (
<div ref={this.setRef}>
<div className='emoji-mart-search'>
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
<button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}><Icon icon={!isSearching ? SearchIcon : CancelIcon} /></button>
</div>
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
{results.map(this.renderItem)}
</div>
</div>
);
}
}
class LanguageDropdown extends PureComponent {
static propTypes = {
value: PropTypes.string,
frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string),
intl: PropTypes.object.isRequired,
onChange: PropTypes.func,
};
state = {
open: false,
placement: 'bottom',
};
handleToggle = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: !this.state.open });
};
handleClose = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: false });
};
handleChange = value => {
const { onChange } = this.props;
onChange(value);
};
setTargetRef = c => {
this.target = c;
};
findTarget = () => {
return this.target;
};
handleOverlayEnter = (state) => {
this.setState({ placement: state.placement });
};
render () {
const { value, intl, frequentlyUsedLanguages } = this.props;
const { open, placement } = this.state;
const current = preloadedLanguages.find(lang => lang[0] === value) ?? [];
return (
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
<button
type='button'
title={intl.formatMessage(messages.changeLanguage)}
aria-expanded={open}
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
className={classNames('dropdown-button', { active: open })}
>
<Icon icon={TranslateIcon} />
<span className='dropdown-button__label'>{current[2] ?? value}</span>
</button>
<Overlay show={open} offset={[5, 5]} placement={placement} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
{({ props, placement }) => (
<div {...props}>
<div className={`dropdown-animation language-dropdown__dropdown ${placement}`} >
<LanguageDropdownMenu
value={value}
frequentlyUsedLanguages={frequentlyUsedLanguages}
onClose={this.handleClose}
onChange={this.handleChange}
intl={intl}
/>
</div>
</div>
)}
</Overlay>
</div>
);
}
}
export default injectIntl(LanguageDropdown);

@ -0,0 +1,427 @@
import { useCallback, useRef, useState, useEffect, useMemo } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { createSelector } from '@reduxjs/toolkit';
import { Map as ImmutableMap } from 'immutable';
import fuzzysort from 'fuzzysort';
import Overlay from 'react-overlays/Overlay';
import type { State, Placement } from 'react-overlays/usePopper';
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import TranslateIcon from '@/material-icons/400-24px/translate.svg?react';
import { changeComposeLanguage } from 'mastodon/actions/compose';
import { Icon } from 'mastodon/components/icon';
import { languages as preloadedLanguages } from 'mastodon/initial_state';
import type { RootState } from 'mastodon/store';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { debouncedGuess } from '../util/language_detection';
const messages = defineMessages({
changeLanguage: {
id: 'compose.language.change',
defaultMessage: 'Change language',
},
search: {
id: 'compose.language.search',
defaultMessage: 'Search languages...',
},
clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
});
type Language = [string, string, string];
const getFrequentlyUsedLanguages = createSelector(
[
(state: RootState) =>
(state.settings as ImmutableMap<string, unknown>).get(
'frequentlyUsedLanguages',
ImmutableMap(),
) as ImmutableMap<string, number>,
],
(languageCounters) =>
languageCounters
.keySeq()
.sort(
(a, b) =>
(languageCounters.get(a) ?? 0) - (languageCounters.get(b) ?? 0),
)
.reverse()
.toArray(),
);
const LanguageDropdownMenu: React.FC<{
value: string;
guess?: string;
onClose: () => void;
onChange: (arg0: string) => void;
}> = ({ value, guess, onClose, onChange }) => {
const languages = preloadedLanguages as Language[];
const intl = useIntl();
const [searchValue, setSearchValue] = useState('');
const nodeRef = useRef<HTMLDivElement>(null);
const listNodeRef = useRef<HTMLDivElement>(null);
const frequentlyUsedLanguages = useAppSelector(getFrequentlyUsedLanguages);
const handleSearchChange = useCallback(
({ target }: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(target.value);
},
[setSearchValue],
);
const handleClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
const value = e.currentTarget.getAttribute('data-index');
if (!value) {
return;
}
e.preventDefault();
onClose();
onChange(value);
},
[onClose, onChange],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!listNodeRef.current) {
return;
}
const index = Array.from(listNodeRef.current.childNodes).findIndex(
(node) => node === e.currentTarget,
);
let element = null;
switch (e.key) {
case 'Escape':
onClose();
break;
case ' ':
case 'Enter':
handleClick(e);
break;
case 'ArrowDown':
element =
listNodeRef.current.childNodes[index + 1] ??
listNodeRef.current.firstChild;
break;
case 'ArrowUp':
element =
listNodeRef.current.childNodes[index - 1] ??
listNodeRef.current.lastChild;
break;
case 'Tab':
if (e.shiftKey) {
element =
listNodeRef.current.childNodes[index - 1] ??
listNodeRef.current.lastChild;
} else {
element =
listNodeRef.current.childNodes[index + 1] ??
listNodeRef.current.firstChild;
}
break;
case 'Home':
element = listNodeRef.current.firstChild;
break;
case 'End':
element = listNodeRef.current.lastChild;
break;
}
if (element && element instanceof HTMLElement) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
},
[onClose, handleClick],
);
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent) => {
let element = null;
if (!listNodeRef.current) {
return;
}
switch (e.key) {
case 'Tab':
case 'ArrowDown':
element = listNodeRef.current.firstChild;
if (element && element instanceof HTMLElement) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
break;
case 'Enter':
element = listNodeRef.current.firstChild;
if (element && element instanceof HTMLElement) {
const value = element.getAttribute('data-index');
if (value) {
onChange(value);
onClose();
}
}
break;
case 'Escape':
if (searchValue !== '') {
e.preventDefault();
setSearchValue('');
}
break;
}
},
[setSearchValue, onChange, onClose, searchValue],
);
const handleClear = useCallback(() => {
setSearchValue('');
}, [setSearchValue]);
const isSearching = searchValue !== '';
useEffect(() => {
const handleDocumentClick = (e: MouseEvent) => {
if (
nodeRef.current &&
e.target instanceof HTMLElement &&
!nodeRef.current.contains(e.target)
) {
onClose();
e.stopPropagation();
}
};
document.addEventListener('click', handleDocumentClick, { capture: true });
// Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
// to wait for a frame before focusing
requestAnimationFrame(() => {
if (nodeRef.current) {
const element = nodeRef.current.querySelector<HTMLInputElement>(
'input[type="search"]',
);
if (element) element.focus();
}
});
return () => {
document.removeEventListener('click', handleDocumentClick);
};
}, [onClose]);
const results = useMemo(() => {
if (searchValue === '') {
return [...languages].sort((a, b) => {
if (guess && a[0] === guess) {
// Push guessed language higher than current selection
return -1;
} else if (guess && b[0] === guess) {
return 1;
} else if (a[0] === value) {
// Push current selection to the top of the list
return -1;
} else if (b[0] === value) {
return 1;
} else {
// Sort according to frequently used languages
const indexOfA = frequentlyUsedLanguages.indexOf(a[0]);
const indexOfB = frequentlyUsedLanguages.indexOf(b[0]);
return (
(indexOfA > -1 ? indexOfA : Infinity) -
(indexOfB > -1 ? indexOfB : Infinity)
);
}
});
}
return fuzzysort
.go(searchValue, languages, {
keys: ['0', '1', '2'],
limit: 5,
threshold: -10000,
})
.map((result) => result.obj);
}, [searchValue, languages, guess, frequentlyUsedLanguages, value]);
return (
<div ref={nodeRef}>
<div className='emoji-mart-search'>
<input
type='search'
value={searchValue}
onChange={handleSearchChange}
onKeyDown={handleSearchKeyDown}
placeholder={intl.formatMessage(messages.search)}
/>
<button
type='button'
className='emoji-mart-search-icon'
disabled={!isSearching}
aria-label={intl.formatMessage(messages.clear)}
onClick={handleClear}
>
<Icon id='' icon={!isSearching ? SearchIcon : CancelIcon} />
</button>
</div>
<div
className='language-dropdown__dropdown__results emoji-mart-scroll'
role='listbox'
ref={listNodeRef}
>
{results.map((lang) => (
<div
key={lang[0]}
role='option'
tabIndex={0}
data-index={lang[0]}
className={classNames(
'language-dropdown__dropdown__results__item',
{ active: lang[0] === value },
)}
aria-selected={lang[0] === value}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
<span
className='language-dropdown__dropdown__results__item__native-name'
lang={lang[0]}
>
{lang[2]}
</span>{' '}
<span className='language-dropdown__dropdown__results__item__common-name'>
({lang[1]})
</span>
</div>
))}
</div>
</div>
);
};
export const LanguageDropdown: React.FC = () => {
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState<Placement | undefined>('bottom');
const [guess, setGuess] = useState('');
const activeElementRef = useRef<HTMLElement | null>(null);
const targetRef = useRef(null);
const intl = useIntl();
const dispatch = useAppDispatch();
const value = useAppSelector(
(state) => state.compose.get('language') as string,
);
const text = useAppSelector((state) => state.compose.get('text') as string);
const current =
(preloadedLanguages as Language[]).find((lang) => lang[0] === value) ?? [];
const handleMouseDown = useCallback(() => {
if (!open && document.activeElement instanceof HTMLElement) {
activeElementRef.current = document.activeElement;
}
}, [open]);
const handleToggle = useCallback(() => {
if (open && activeElementRef.current)
activeElementRef.current.focus({ preventScroll: true });
setOpen(!open);
}, [open, setOpen]);
const handleClose = useCallback(() => {
if (open && activeElementRef.current)
activeElementRef.current.focus({ preventScroll: true });
setOpen(false);
}, [open, setOpen]);
const handleChange = useCallback(
(value: string) => {
dispatch(changeComposeLanguage(value));
},
[dispatch],
);
const handleOverlayEnter = useCallback(
(state: Partial<State>) => {
setPlacement(state.placement);
},
[setPlacement],
);
useEffect(() => {
if (text.length > 20) {
debouncedGuess(text, setGuess);
} else {
setGuess('');
}
}, [text, setGuess]);
return (
<div ref={targetRef}>
<button
type='button'
title={intl.formatMessage(messages.changeLanguage)}
aria-expanded={open}
onClick={handleToggle}
onMouseDown={handleMouseDown}
className={classNames('dropdown-button', {
active: open,
warning: guess !== '' && guess !== value,
})}
>
<Icon id='' icon={TranslateIcon} />
<span className='dropdown-button__label'>{current[2] ?? value}</span>
</button>
<Overlay
show={open}
offset={[5, 5]}
placement={placement}
flip
target={targetRef}
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
>
{({ props, placement }) => (
<div {...props}>
<div
className={`dropdown-animation language-dropdown__dropdown ${placement}`}
>
<LanguageDropdownMenu
value={value}
guess={guess}
onClose={handleClose}
onChange={handleChange}
/>
</div>
</div>
)}
</Overlay>
</div>
);
};

@ -4,16 +4,16 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import {
undoUploadCompose,
initMediaEditModal,
} from 'mastodon/actions/compose';
import { undoUploadCompose } from 'mastodon/actions/compose';
import { openModal } from 'mastodon/actions/modal';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
@ -27,16 +27,15 @@ export const Upload: React.FC<{
wide?: boolean;
}> = ({ id, dragging, overlay, tall, wide }) => {
const dispatch = useAppDispatch();
const media = useAppSelector(
(state) =>
state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
.get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
.find((item: MediaAttachment) => item.get('id') === id) as // eslint-disable-line @typescript-eslint/no-unsafe-member-access
| MediaAttachment
| undefined,
const media = useAppSelector((state) =>
(
(state.compose as ImmutableMap<string, unknown>).get(
'media_attachments',
) as ImmutableList<MediaAttachment>
).find((item) => item.get('id') === id),
);
const sensitive = useAppSelector(
(state) => state.compose.get('spoiler') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
(state) => state.compose.get('spoiler') as boolean,
);
const handleUndoClick = useCallback(() => {
@ -44,7 +43,9 @@ export const Upload: React.FC<{
}, [dispatch, id]);
const handleFocalPointClick = useCallback(() => {
dispatch(initMediaEditModal(id));
dispatch(
openModal({ modalType: 'FOCAL_POINT', modalProps: { mediaId: id } }),
);
}, [dispatch, id]);
const { attributes, listeners, setNodeRef, transform, transition } =

@ -2,7 +2,11 @@ import { useState, useCallback, useMemo } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import type { List } from 'immutable';
import type {
List,
Map as ImmutableMap,
List as ImmutableList,
} from 'immutable';
import type {
DragStartEvent,
@ -63,18 +67,20 @@ export const UploadForm: React.FC = () => {
const intl = useIntl();
const mediaIds = useAppSelector(
(state) =>
state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
.get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
.map((item: MediaAttachment) => item.get('id')) as List<string>, // eslint-disable-line @typescript-eslint/no-unsafe-member-access
(
(state.compose as ImmutableMap<string, unknown>).get(
'media_attachments',
) as ImmutableList<MediaAttachment>
).map((item: MediaAttachment) => item.get('id')) as List<string>,
);
const active = useAppSelector(
(state) => state.compose.get('is_uploading') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
(state) => state.compose.get('is_uploading') as boolean,
);
const progress = useAppSelector(
(state) => state.compose.get('progress') as number, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
(state) => state.compose.get('progress') as number,
);
const isProcessing = useAppSelector(
(state) => state.compose.get('is_processing') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
(state) => state.compose.get('is_processing') as boolean,
);
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
const sensors = useSensors(

@ -9,7 +9,9 @@ import {
changeComposeSpoilerText,
insertEmojiCompose,
uploadCompose,
} from '../../../actions/compose';
} from 'mastodon/actions/compose';
import { openModal } from 'mastodon/actions/modal';
import ComposeForm from '../components/compose_form';
const mapStateToProps = state => ({
@ -26,6 +28,7 @@ const mapStateToProps = state => ({
isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
isUploading: state.getIn(['compose', 'is_uploading']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
missingAltText: state.getIn(['compose', 'media_attachments']).some(media => ['image', 'gifv'].includes(media.get('type')) && (media.get('description') ?? '').length === 0),
isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
lang: state.getIn(['compose', 'language']),
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
@ -37,8 +40,15 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(changeCompose(text));
},
onSubmit () {
dispatch(submitCompose());
onSubmit (missingAltText) {
if (missingAltText) {
dispatch(openModal({
modalType: 'CONFIRM_MISSING_ALT_TEXT',
modalProps: {},
}));
} else {
dispatch(submitCompose());
}
},
onClearSuggestions () {

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

Loading…
Cancel
Save