Merge branch 'main' into translate-toots

This commit is contained in:
Thomas Steiner 2025-11-11 01:10:59 +01:00 committed by GitHub
commit b4dd2a96f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
426 changed files with 6677 additions and 4636 deletions

View File

@ -73,7 +73,7 @@ services:
hard: -1 hard: -1
libretranslate: libretranslate:
image: libretranslate/libretranslate:v1.6.2 image: libretranslate/libretranslate:v1.7.3
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- lt-data:/home/libretranslate/.local - lt-data:/home/libretranslate/.local

View File

@ -88,21 +88,3 @@ S3_ALIAS_HOST=files.example.com
# ----------------------- # -----------------------
IP_RETENTION_PERIOD=31556952 IP_RETENTION_PERIOD=31556952
SESSION_RETENTION_PERIOD=31556952 SESSION_RETENTION_PERIOD=31556952
# Fetch All Replies Behavior
# --------------------------
# Period to wait between fetching replies (in minutes)
FETCH_REPLIES_COOLDOWN_MINUTES=15
# Period to wait after a post is first created before fetching its replies (in minutes)
FETCH_REPLIES_INITIAL_WAIT_MINUTES=5
# Max number of replies to fetch - total, recursively through a whole reply tree
FETCH_REPLIES_MAX_GLOBAL=1000
# Max number of replies to fetch - for a single post
FETCH_REPLIES_MAX_SINGLE=500
# Max number of replies Collection pages to fetch - total
FETCH_REPLIES_MAX_PAGES=500

View File

@ -9,7 +9,7 @@ runs:
using: 'composite' using: 'composite'
steps: steps:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'

View File

@ -23,8 +23,6 @@
// Require Dependency Dashboard Approval for major version bumps of these node packages // Require Dependency Dashboard Approval for major version bumps of these node packages
matchManagers: ['npm'], matchManagers: ['npm'],
matchPackageNames: [ matchPackageNames: [
'tesseract.js', // Requires code changes
// react-router: Requires manual upgrade // react-router: Requires manual upgrade
'history', 'history',
'react-router-dom', 'react-router-dom',

View File

@ -35,7 +35,7 @@ jobs:
- linux/arm64 - linux/arm64
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Prepare - name: Prepare
env: env:
@ -100,7 +100,7 @@ jobs:
- name: Upload digest - name: Upload digest
if: ${{ inputs.push_to_images != '' }} if: ${{ inputs.push_to_images != '' }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
# `hashFiles` is used to disambiguate between streaming and non-streaming images # `hashFiles` is used to disambiguate between streaming and non-streaming images
name: digests-${{ hashFiles(inputs.file_to_build) }}-${{ env.PLATFORM_PAIR }} name: digests-${{ hashFiles(inputs.file_to_build) }}-${{ env.PLATFORM_PAIR }}
@ -119,10 +119,10 @@ jobs:
PUSH_TO_IMAGES: ${{ inputs.push_to_images }} PUSH_TO_IMAGES: ${{ inputs.push_to_images }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Download digests - name: Download digests
uses: actions/download-artifact@v4 uses: actions/download-artifact@v6
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
# `hashFiles` is used to disambiguate between streaming and non-streaming images # `hashFiles` is used to disambiguate between streaming and non-streaming images

View File

@ -18,7 +18,7 @@ jobs:
steps: steps:
# Repository needs to be cloned so `git rev-parse` below works # Repository needs to be cloned so `git rev-parse` below works
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- id: version_vars - id: version_vars
run: | run: |
echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT

View File

@ -21,7 +21,7 @@ jobs:
# Only tag with latest when ran against the latest stable branch # Only tag with latest when ran against the latest stable branch
# This needs to be updated after each minor version release # This needs to be updated after each minor version release
flavor: | flavor: |
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }} latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }}
tags: | tags: |
type=pep440,pattern={{raw}} type=pep440,pattern={{raw}}
type=pep440,pattern=v{{major}}.{{minor}} type=pep440,pattern=v{{major}}.{{minor}}
@ -39,7 +39,7 @@ jobs:
# Only tag with latest when ran against the latest stable branch # Only tag with latest when ran against the latest stable branch
# This needs to be updated after each minor version release # This needs to be updated after each minor version release
flavor: | flavor: |
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }} latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }}
tags: | tags: |
type=pep440,pattern={{raw}} type=pep440,pattern={{raw}}
type=pep440,pattern=v{{major}}.{{minor}} type=pep440,pattern=v{{major}}.{{minor}}

View File

@ -28,7 +28,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1

View File

@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set up Ruby environment - name: Set up Ruby environment
uses: ./.github/actions/setup-ruby uses: ./.github/actions/setup-ruby

View File

@ -23,7 +23,7 @@ jobs:
if: github.repository == 'mastodon/mastodon' if: github.repository == 'mastodon/mastodon'
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Javascript environment - name: Set up Javascript environment
@ -33,7 +33,7 @@ jobs:
run: yarn build-storybook run: yarn build-storybook
- name: Run Chromatic - name: Run Chromatic
uses: chromaui/action@v12 uses: chromaui/action@v13
with: with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret # ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}

View File

@ -31,11 +31,11 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v4
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@ -48,7 +48,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v3 uses: github/codeql-action/autobuild@v4
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@ -61,6 +61,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3 uses: github/codeql-action/analyze@v4
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'

View File

@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Increase Git http.postBuffer - name: Increase Git http.postBuffer
# This is needed due to a bug in Ubuntu's cURL version? # This is needed due to a bug in Ubuntu's cURL version?

View File

@ -15,7 +15,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Increase Git http.postBuffer - name: Increase Git http.postBuffer
# This is needed due to a bug in Ubuntu's cURL version? # This is needed due to a bug in Ubuntu's cURL version?

View File

@ -23,7 +23,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@v2 uses: crowdin/github-action@v2

View File

@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Javascript environment - name: Set up Javascript environment
uses: ./.github/actions/setup-javascript uses: ./.github/actions/setup-javascript

View File

@ -34,7 +34,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Javascript environment - name: Set up Javascript environment
uses: ./.github/actions/setup-javascript uses: ./.github/actions/setup-javascript

View File

@ -33,7 +33,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1

View File

@ -38,7 +38,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Javascript environment - name: Set up Javascript environment
uses: ./.github/actions/setup-javascript uses: ./.github/actions/setup-javascript

View File

@ -35,7 +35,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1

View File

@ -34,7 +34,7 @@ jobs:
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Javascript environment - name: Set up Javascript environment
uses: ./.github/actions/setup-javascript uses: ./.github/actions/setup-javascript

View File

@ -72,7 +72,7 @@ jobs:
BUNDLE_RETRY: 3 BUNDLE_RETRY: 3
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set up Ruby environment - name: Set up Ruby environment
uses: ./.github/actions/setup-ruby uses: ./.github/actions/setup-ruby

View File

@ -32,7 +32,7 @@ jobs:
SECRET_KEY_BASE_DUMMY: 1 SECRET_KEY_BASE_DUMMY: 1
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set up Ruby environment - name: Set up Ruby environment
uses: ./.github/actions/setup-ruby uses: ./.github/actions/setup-ruby
@ -65,7 +65,7 @@ jobs:
run: | run: |
tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* tmp/cache/vite/last-build*.json tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* tmp/cache/vite/last-build*.json
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v5
if: matrix.mode == 'test' if: matrix.mode == 'test'
with: with:
path: |- path: |-
@ -128,9 +128,9 @@ jobs:
- '3.3' - '3.3'
- '.ruby-version' - '.ruby-version'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v6
with: with:
path: './' path: './'
name: ${{ github.sha }} name: ${{ github.sha }}
@ -230,9 +230,9 @@ jobs:
- '3.3' - '3.3'
- '.ruby-version' - '.ruby-version'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v6
with: with:
path: './' path: './'
name: ${{ github.sha }} name: ${{ github.sha }}
@ -309,9 +309,9 @@ jobs:
- '.ruby-version' - '.ruby-version'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v6
with: with:
path: './' path: './'
name: ${{ github.sha }} name: ${{ github.sha }}
@ -350,14 +350,14 @@ jobs:
- run: bin/rspec spec/system --tag streaming --tag js - run: bin/rspec spec/system --tag streaming --tag js
- name: Archive logs - name: Archive logs
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
if: failure() if: failure()
with: with:
name: e2e-logs-${{ matrix.ruby-version }} name: e2e-logs-${{ matrix.ruby-version }}
path: log/ path: log/
- name: Archive test screenshots - name: Archive test screenshots
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
if: failure() if: failure()
with: with:
name: e2e-screenshots-${{ matrix.ruby-version }} name: e2e-screenshots-${{ matrix.ruby-version }}
@ -447,9 +447,9 @@ jobs:
search-image: opensearchproject/opensearch:2 search-image: opensearchproject/opensearch:2
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v6
with: with:
path: './' path: './'
name: ${{ github.sha }} name: ${{ github.sha }}
@ -469,14 +469,14 @@ jobs:
- run: bin/rspec --tag search - run: bin/rspec --tag search
- name: Archive logs - name: Archive logs
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
if: failure() if: failure()
with: with:
name: test-search-logs-${{ matrix.ruby-version }} name: test-search-logs-${{ matrix.ruby-version }}
path: log/ path: log/
- name: Archive test screenshots - name: Archive test screenshots
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
if: failure() if: failure()
with: with:
name: test-search-screenshots name: test-search-screenshots

1
.gitignore vendored
View File

@ -23,6 +23,7 @@
/public/packs /public/packs
/public/packs-dev /public/packs-dev
/public/packs-test /public/packs-test
stats.html
.env .env
.env.production .env.production
node_modules/ node_modules/

2
.nvmrc
View File

@ -1 +1 @@
24.10 24.11

View File

@ -31,7 +31,7 @@ const config: StorybookConfig = {
viteFinal(config) { viteFinal(config) {
// For an unknown reason, Storybook does not use the root // For an unknown reason, Storybook does not use the root
// from the Vite config so we need to set it manually. // from the Vite config so we need to set it manually.
config.root = resolve(__dirname, '../app/javascript'); config.root = resolve(import.meta.dirname, '../app/javascript');
return config; return config;
}, },
}; };

View File

@ -2,45 +2,61 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [4.5.0] - UNRELEASED ## [4.5.0] - 2025-11-06
### Added ### Added
- **Add support for allowing and authoring quotes** (#35355, #35578, #35614, #35618, #35624, #35626, #35652, #35629, #35665, #35653, #35670, #35677, #35690, #35697, #35689, #35699, #35700, #35701, #35709, #35714, #35713, #35715, #35725, #35749, #35769, #35780, #35762, #35804, #35808, #35805, #35819, #35824, #35828, #35822, #35835, #35865, #35860, #35832, #35891, #35894, #35895, #35820, #35917, #35924, #35925, #35914, #35930, #35941, #35939, #35948, #35955, #35967, #35990, #35991, #35975, #35971, #36002, #35986, #36031, #36034, #36038, #36054, #36052, #36055, #36065, #36068, #36083, #36087, #36080, #36091, #36090, #36118, #36119, #36128, #36094, #36129, #36138, #36132, #36151, #36158, #36171, #36194, #36220, #36169, #36130, #36249, #36153, #36299, #36291, #36301, #36315, #36317, #36364, #36383, #36381, #36459, #36464, #36461, #36516 and #36528 by @ChaosExAnima, @ClearlyClaire, @Lycolia, @diondiondion, and @tribela)\ - **Add support for allowing and authoring quotes** (#35355, #35578, #35614, #35618, #35624, #35626, #35652, #35629, #35665, #35653, #35670, #35677, #35690, #35697, #35689, #35699, #35700, #35701, #35709, #35714, #35713, #35715, #35725, #35749, #35769, #35780, #35762, #35804, #35808, #35805, #35819, #35824, #35828, #35822, #35835, #35865, #35860, #35832, #35891, #35894, #35895, #35820, #35917, #35924, #35925, #35914, #35930, #35941, #35939, #35948, #35955, #35967, #35990, #35991, #35975, #35971, #36002, #35986, #36031, #36034, #36038, #36054, #36052, #36055, #36065, #36068, #36083, #36087, #36080, #36091, #36090, #36118, #36119, #36128, #36094, #36129, #36138, #36132, #36151, #36158, #36171, #36194, #36220, #36169, #36130, #36249, #36153, #36299, #36291, #36301, #36315, #36317, #36364, #36383, #36381, #36459, #36464, #36461, #36516, #36528, #36549, #36550, #36559, #36693, #36704, #36690, #36689, #36696, #36721, #36695 and #36736 by @ChaosExAnima, @ClearlyClaire, @Lycolia, @diondiondion, and @tribela)\
This includes a revamp of the composer interface.\ This includes a revamp of the composer interface.\
See https://blog.joinmastodon.org/2025/09/introducing-quote-posts/ for a user-centric overview of the feature, and https://docs.joinmastodon.org/client/quotes/ for API documentation. See https://blog.joinmastodon.org/2025/09/introducing-quote-posts/ for a user-centric overview of the feature, and https://docs.joinmastodon.org/client/quotes/ for API documentation.
- **Add support for fetching and refreshing replies to the web UI** (#35210, #35496, #35575, #35500, #35577, #35602, #35603, #35654, #36141, #36237, #36172, #36256, #36271, #36334, #36382, #36239, #36484 and #36481 by @ClearlyClaire, @Gargron, and @diondiondion) - **Add support for fetching and refreshing replies to the web UI** (#35210, #35496, #35575, #35500, #35577, #35602, #35603, #35654, #36141, #36237, #36172, #36256, #36271, #36334, #36382, #36239, #36484, #36481, #36583, #36627 and #36547 by @ClearlyClaire, @diondiondion, @Gargron and @renchap)
- **Add ability to block words in usernames** (#35407, #35655, and #35806 by @ClearlyClaire and @Gargron) - **Add ability to block words in usernames** (#35407, #35655, and #35806 by @ClearlyClaire and @Gargron)
- Add ability to individually disable local or remote feeds for visitors or logged-in users `disabled` value to server setting for live and topic feeds, as well as user permission to bypass that (#36338, #36467, #36497, #36563, #36577, #36585, #36607 and #36703 by @ClearlyClaire)\
This splits the `timeline_preview` setting into four more granular settings controlling live feeds and topic (hashtag, trending link) feeds.\
The setting for local topic feeds has 2 values: `public` and `authenticated`. Every other setting has 3 values: `public`, `authenticated`, `disabled`.\
When `disabled`, users with the “View live and topic feeds” will still be able to view them.
- Add support for displaying of quote posts in Moderator UI (#35964 by @ThisIsMissEm) - Add support for displaying of quote posts in Moderator UI (#35964 by @ThisIsMissEm)
- Add support for displaying link previews for Admin UI (#35958 by @ThisIsMissEm) - Add support for displaying link previews for Admin UI (#35958 by @ThisIsMissEm)
- Add a new server setting to choose the server landing page (#36588 and #36602 by @ClearlyClaire and @renchap)
- Add support for `Update` activities on converted object types (#36322 by @ClearlyClaire)
- Add support for dynamic viewport height (#36272 by @e1berd) - Add support for dynamic viewport height (#36272 by @e1berd)
- Add support for numeric-based URIs for new local accounts (#32724, #36304, #36316, and #36365 by @ClearlyClaire) - Add support for numeric-based URIs for new local accounts (#32724, #36304, #36316, and #36365 by @ClearlyClaire)
- Add default visualizer for audio upload without poster (#36734 by @ChaosExAnima)
- Add Traditional Mongolian to posting languages (#36196 by @shimon1024) - Add Traditional Mongolian to posting languages (#36196 by @shimon1024)
- Add example post with manual quote approval policy to `dev:populate_sample_data` (#36099 by @ClearlyClaire) - Add example post with manual quote approval policy to `dev:populate_sample_data` (#36099 by @ClearlyClaire)
- Add server-side support for handling posts with a quote policy allowing followers to quote (#36093 and #36127 by @ClearlyClaire) - Add server-side support for handling posts with a quote policy allowing followers to quote (#36093 and #36127 by @ClearlyClaire)
- Add schema.org markup to SEO-enabled posts (#36075 by @Gargron) - Add schema.org markup to SEO-enabled posts (#36075 by @Gargron)
- Add migration to fill unset default quote policy based on default post privacy (#36041 by @ClearlyClaire) - Add migration to fill unset default quote policy based on default post privacy (#36041 by @ClearlyClaire)
- Add “Posting defaults” setting page, moving existing settings from “Other” (#35896, #36033, #35966, #35969, and #36084 by @ClearlyClaire and @diondiondion)
- Added emoji from Twemoji v16 (#36501 and #36530 by @ChaosExAnima)
- Add feature to select custom emoji rendering (#35229, #35282, #35253, #35424, #35473, #35483, #35505, #35568, #35605, #35659, #35664, #35739, #35985, #36051, #36071, #36137, #36165, #36248, #36262, #36275, #36293, #36341, #36342, #36366, #36377, #36378, #36385, #36393, #36397, #36403, #36413, #36410, #36454, #36402, #36503, #36502, #36532, #36603, #36409, #36638 and #36750 by @ChaosExAnima, @ClearlyClaire and @braddunbar)\
This also completely reworks the processing and rendering of emojis and server-rendered HTML in statuses and other places.
- Add support for exposing conversation context for new public conversations according to FEP-7888 (#35959 and #36064 by @ClearlyClaire and @jesseplusplus) - Add support for exposing conversation context for new public conversations according to FEP-7888 (#35959 and #36064 by @ClearlyClaire and @jesseplusplus)
- Add digest re-check before removing followers in synchronization mechanism (#34273 by @ClearlyClaire) - Add digest re-check before removing followers in synchronization mechanism (#34273 by @ClearlyClaire)
- Add “Posting defaults” setting page, moving existing settings from “Other” (#35896, #36033, #35966, #35969, and #36084 by @ClearlyClaire and @diondiondion)
- Add support for displaying Valkey version on admin dashboard (#35785 by @ykzts) - Add support for displaying Valkey version on admin dashboard (#35785 by @ykzts)
- Add delivery failure tracking and handling to FASP jobs (#35625, #35628, and #35723 by @oneiros) - Add delivery failure tracking and handling to FASP jobs (#35625, #35628, and #35723 by @oneiros)
- Add example of quote post with a preview card to development sample data (#35616 by @ClearlyClaire) - Add example of quote post with a preview card to development sample data (#35616 by @ClearlyClaire)
- Add second set of blocked text that applies to accounts regardless of account age for spam-blocking (#35563 by @ClearlyClaire) - Add second set of blocked text that applies to accounts regardless of account age for spam-blocking (#35563 by @ClearlyClaire)
- Added emoji from Twemoji v16 (#36501 and #36530 by @ChaosExAnima)
- Add experimental feature to select custom emoji rendering (#35229, #35282, #35253, #35424, #35473, #35483, #35505, #35568, #35605, #35659, #35664, #35739, #35985, #36051, #36071, #36137, #36165, #36248, #36262, #36275, #36293, #36341, #36342, #36366, #36377, #36378, #36385, #36393, #36397, #36403, #36413, #36410, #36454, #36402, #36503, #36502 and #36532 by @ChaosExAnima and @braddunbar)\
This also completely reworks the processing and rendering of emojis and server-rendered HTML in statuses and other places.
### Changed ### Changed
- Change confirmation dialogs for follow button actions “unfollow”, “unblock”, and “withdraw request” (#36289 by @diondiondion) - Change confirmation dialogs for follow button actions “unfollow”, “unblock”, and “withdraw request” (#36289 by @diondiondion)
- Change “Follow” button labels (#36264 by @diondiondion) - Change “Follow” button labels (#36264 by @diondiondion)
- Change appearance settings to introduce new Advanced settings section (#36496 and #36506 by @diondiondion) - Change appearance settings to introduce new Advanced settings section (#36496 and #36506 by @diondiondion)
- Change display of blocked and muted quoted users (#36619 by @ClearlyClaire)\
This adds `blocked_account`, `blocked_domain` and `muted_account` values to the `state` attribute of `Quote` and `ShallowQuote` REST API entities.
- Change submitting an empty post to show an error rather than failing silently (#36650 by @diondiondion)
- Change "Privacy and reach" settings from "Public profile" to their own top-level category (#27294 by @ChaelCodes)
- Change number of times quote verification is retried to better deal with temporary failures (#36698 by @ClearlyClaire)
- Change display of content warnings in Admin UI (#35935 by @ThisIsMissEm) - Change display of content warnings in Admin UI (#35935 by @ThisIsMissEm)
- Change styling of column banners (#36531 by @ClearlyClaire)
- Change recommended Node version to 24 (LTS) (#36539 by @renchap)
- Change min. characters required for logged-out account search from 5 to 3 (#36487 by @Gargron)
- Change browser target to Vite legacy plugin defaults (#36611 by @larouxn)
- Change index on `follows` table to improve performance of some queries (#36374 by @ClearlyClaire) - Change index on `follows` table to improve performance of some queries (#36374 by @ClearlyClaire)
- Change links to accounts in settings and moderation views to link to local view unless account is suspended (#36340 by @diondiondion) - Change links to accounts in settings and moderation views to link to local view unless account is suspended (#36340 by @diondiondion)
- Change redirection for denied registration from web app to sign-in page with error message (#36384 by @ClearlyClaire) - Change redirection for denied registration from web app to sign-in page with error message (#36384 by @ClearlyClaire)
- Change `timeline_preview` setting into four more granular settings (#36338, #36467 and #36497 by @ClearlyClaire) - Change support for RFC9421 HTTP signatures to be enabled unconditionally (#36610 by @oneiros)
- Change wording and design of interaction dialog to simplify it (#36124 by @diondiondion) - Change wording and design of interaction dialog to simplify it (#36124 by @diondiondion)
- Change dropdown menus to allow disabled items to be focused (#36078 by @diondiondion) - Change dropdown menus to allow disabled items to be focused (#36078 by @diondiondion)
- Change modal background colours in light mode (#36069 by @diondiondion) - Change modal background colours in light mode (#36069 by @diondiondion)
@ -48,7 +64,7 @@ All notable changes to this project will be documented in this file.
- Change description of “Quiet public” (#36032 by @ClearlyClaire) - Change description of “Quiet public” (#36032 by @ClearlyClaire)
- Change “Boost with original visibility” to “Share again with your followers” (#36035 by @ClearlyClaire) - Change “Boost with original visibility” to “Share again with your followers” (#36035 by @ClearlyClaire)
- Change handling of push subscriptions to automatically delete invalid ones on delivery (#35987 by @ThisIsMissEm) - Change handling of push subscriptions to automatically delete invalid ones on delivery (#35987 by @ThisIsMissEm)
- Change design of quote posts in web UI (#35584 and #35834 by @ClearlyClaire and @Gargron) - Change design of quote posts in web UI (#35584 and #35834 by @Gargron)
- Change auditable accounts to be sorted by username in admin action logs interface (#35272 by @breadtk) - Change auditable accounts to be sorted by username in admin action logs interface (#35272 by @breadtk)
- Change order of translation restoration and service credit on post card (#33619 by @colindean) - Change order of translation restoration and service credit on post card (#33619 by @colindean)
- Change position of add more to be inside table toolbar on reports (#35963 by @ThisIsMissEm) - Change position of add more to be inside table toolbar on reports (#35963 by @ThisIsMissEm)
@ -59,6 +75,16 @@ All notable changes to this project will be documented in this file.
- Fix relationship not being fetched to evaluate whether to show a quote post (#36517 by @ClearlyClaire) - Fix relationship not being fetched to evaluate whether to show a quote post (#36517 by @ClearlyClaire)
- Fix rendering of poll options in status history modal (#35633 by @ThisIsMissEm) - Fix rendering of poll options in status history modal (#35633 by @ThisIsMissEm)
- Fix “mute” button being displayed to unauthenticated visitors in hashtag dropdown (#36353 by @mkljczk) - Fix “mute” button being displayed to unauthenticated visitors in hashtag dropdown (#36353 by @mkljczk)
- Fix initially selected language in Rules panel, hide selector when no alternative translations exist (#36672 by @diondiondion)
- Fix URL comparison for mentions in case of empty path (#36613 and #36626 by @ClearlyClaire)
- Fix hashtags not being picked up when full-width hash sign is used (#36103 and #36625 by @ClearlyClaire and @Gargron)
- Fix layout of severed relationships when purged events are listed (#36593 by @mejofi)
- Fix Skeleton placeholders being animated when setting to reduce animations is enabled (#36716 by @ClearlyClaire)
- Fix vacuum tasks being interrupted by a single batch failure (#36606 by @Gargron)
- Fix handling of unreachable network error for search services (#36587 by @mjankowski)
- Fix bookmarks export when a bookmarked status is soft-deleted (#36576 by @ClearlyClaire)
- Fix text overflow alignment for long author names in News (#36562 by @diondiondion)
- Fix discovery preamble missing word in admin settings (#36560 by @belatedly)
- Fix overflow handling of `.more-from-author` (#36310 by @edent) - Fix overflow handling of `.more-from-author` (#36310 by @edent)
- Fix unfortunate action button wrapping in admin area (#36247 by @diondiondion) - Fix unfortunate action button wrapping in admin area (#36247 by @diondiondion)
- Fix translate button width in Safari (#36164 and #36216 by @diondiondion) - Fix translate button width in Safari (#36164 and #36216 by @diondiondion)
@ -81,6 +107,10 @@ All notable changes to this project will be documented in this file.
- Fix glitchy status keyboard navigation (#35455 and #35504 by @diondiondion) - Fix glitchy status keyboard navigation (#35455 and #35504 by @diondiondion)
- Fix post being submitted when pressing “Enter” in the CW field (#35445 by @diondiondion) - Fix post being submitted when pressing “Enter” in the CW field (#35445 by @diondiondion)
### Removed
- Remove support for PostgreSQL 13 (#36540 by @renchap)
## [4.4.8] - 2025-10-21 ## [4.4.8] - 2025-10-21
### Security ### Security

View File

@ -183,7 +183,7 @@ FROM build AS libvips
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
ARG VIPS_VERSION=8.17.2 ARG VIPS_VERSION=8.17.3
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"] # libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download ARG VIPS_URL=https://github.com/libvips/libvips/releases/download

View File

@ -13,7 +13,7 @@ gem 'haml-rails', '~>3.0'
gem 'pg', '~> 1.5' gem 'pg', '~> 1.5'
gem 'pghero' gem 'pghero'
gem 'aws-sdk-core', '< 3.216.0', require: false # TODO: https://github.com/mastodon/mastodon/pull/34173#issuecomment-2733378873 gem 'aws-sdk-core', require: false
gem 'aws-sdk-s3', '~> 1.123', require: false gem 'aws-sdk-s3', '~> 1.123', require: false
gem 'blurhash', '~> 0.1' gem 'blurhash', '~> 0.1'
gem 'fog-core', '<= 2.6.0' gem 'fog-core', '<= 2.6.0'
@ -114,7 +114,7 @@ group :opentelemetry do
gem 'opentelemetry-instrumentation-http', '~> 0.27.0', require: false gem 'opentelemetry-instrumentation-http', '~> 0.27.0', require: false
gem 'opentelemetry-instrumentation-http_client', '~> 0.26.0', require: false gem 'opentelemetry-instrumentation-http_client', '~> 0.26.0', require: false
gem 'opentelemetry-instrumentation-net_http', '~> 0.26.0', require: false gem 'opentelemetry-instrumentation-net_http', '~> 0.26.0', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.32.0', require: false gem 'opentelemetry-instrumentation-pg', '~> 0.33.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false
@ -138,7 +138,7 @@ group :test do
# Browser integration testing # Browser integration testing
gem 'capybara', '~> 3.39' gem 'capybara', '~> 3.39'
gem 'capybara-playwright-driver' gem 'capybara-playwright-driver'
gem 'playwright-ruby-client', '1.55.0', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package gem 'playwright-ruby-client', '1.56.0', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package
# Used to reset the database between system tests # Used to reset the database between system tests
gem 'database_cleaner-active_record' gem 'database_cleaner-active_record'

View File

@ -90,23 +90,26 @@ GEM
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0) aes_key_wrap (1.1.0)
android_key_attestation (0.3.0) android_key_attestation (0.3.0)
annotaterb (4.19.0) annotaterb (4.20.0)
activerecord (>= 6.0.0) activerecord (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
ast (2.4.3) ast (2.4.3)
attr_required (1.0.2) attr_required (1.0.2)
aws-eventstream (1.4.0) aws-eventstream (1.4.0)
aws-partitions (1.1168.0) aws-partitions (1.1180.0)
aws-sdk-core (3.215.1) aws-sdk-core (3.236.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.96.0) logger
aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (1.116.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.177.0) aws-sdk-s3 (1.203.0)
aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1) aws-sigv4 (1.12.1)
@ -116,7 +119,7 @@ GEM
base64 (0.3.0) base64 (0.3.0)
bcp47_spec (0.2.1) bcp47_spec (0.2.1)
bcrypt (3.1.20) bcrypt (3.1.20)
benchmark (0.4.1) benchmark (0.5.0)
better_errors (2.10.1) better_errors (2.10.1)
erubi (>= 1.0.0) erubi (>= 1.0.0)
rack (>= 0.9.0) rack (>= 0.9.0)
@ -128,7 +131,7 @@ GEM
blurhash (0.1.8) blurhash (0.1.8)
bootsnap (1.18.6) bootsnap (1.18.6)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.0.2) brakeman (7.1.1)
racc racc
browser (6.2.0) browser (6.2.0)
builder (3.3.0) builder (3.3.0)
@ -168,7 +171,7 @@ GEM
cose (1.3.1) cose (1.3.1)
cbor (~> 0.5.9) cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0) openssl-signature_algorithm (~> 1.0)
crack (1.0.0) crack (1.0.1)
bigdecimal bigdecimal
rexml rexml
crass (1.0.6) crass (1.0.6)
@ -190,10 +193,10 @@ GEM
railties (>= 4.1.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise-two-factor (6.1.0) devise-two-factor (6.2.0)
activesupport (>= 7.0, < 8.1) activesupport (>= 7.0, < 8.2)
devise (~> 4.0) devise (~> 4.0)
railties (>= 7.0, < 8.1) railties (>= 7.0, < 8.2)
rotp (~> 6.0) rotp (~> 6.0)
devise_pam_authenticatable2 (9.2.0) devise_pam_authenticatable2 (9.2.0)
devise (>= 4.0.0) devise (>= 4.0.0)
@ -224,7 +227,7 @@ GEM
mail (~> 2.7) mail (~> 2.7)
email_validator (2.2.4) email_validator (2.2.4)
activemodel activemodel
erb (5.0.2) erb (5.1.3)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.4.0) et-orbi (1.4.0)
tzinfo tzinfo
@ -279,7 +282,7 @@ GEM
rake (>= 13) rake (>= 13)
googleapis-common-protos-types (1.22.0) googleapis-common-protos-types (1.22.0)
google-protobuf (~> 4.26) google-protobuf (~> 4.26)
haml (6.3.0) haml (6.4.0)
temple (>= 0.8.2) temple (>= 0.8.2)
thor thor
tilt tilt
@ -288,7 +291,7 @@ GEM
activesupport (>= 5.1) activesupport (>= 5.1)
haml (>= 4.0.6) haml (>= 4.0.6)
railties (>= 5.1) railties (>= 5.1)
haml_lint (0.66.0) haml_lint (0.67.0)
haml (>= 5.0) haml (>= 5.0)
parallel (~> 1.10) parallel (~> 1.10)
rainbow rainbow
@ -337,7 +340,7 @@ GEM
activesupport (>= 3.0) activesupport (>= 3.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
io-console (0.8.1) io-console (0.8.1)
irb (1.15.2) irb (1.15.3)
pp (>= 0.6.0) pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
@ -346,7 +349,7 @@ GEM
azure-blob (~> 0.5.2) azure-blob (~> 0.5.2)
hashie (~> 5.0) hashie (~> 5.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.15.1) json (2.15.2)
json-canonicalization (1.0.0) json-canonicalization (1.0.0)
json-jwt (1.17.0) json-jwt (1.17.0)
activesupport (>= 4.2) activesupport (>= 4.2)
@ -443,7 +446,7 @@ GEM
mime-types-data (3.2025.0924) mime-types-data (3.2025.0924)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.9) mini_portile2 (2.8.9)
minitest (5.25.5) minitest (5.26.0)
msgpack (1.8.0) msgpack (1.8.0)
multi_json (1.17.0) multi_json (1.17.0)
mutex_m (0.3.0) mutex_m (0.3.0)
@ -465,7 +468,7 @@ GEM
nokogiri (1.18.10) nokogiri (1.18.10)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
oj (3.16.11) oj (3.16.12)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
omniauth (2.1.4) omniauth (2.1.4)
@ -512,9 +515,9 @@ GEM
opentelemetry-common (~> 0.20) opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.10) opentelemetry-sdk (~> 1.10)
opentelemetry-semantic_conventions opentelemetry-semantic_conventions
opentelemetry-helpers-sql (0.2.0) opentelemetry-helpers-sql (0.3.0)
opentelemetry-api (~> 1.7) opentelemetry-api (~> 1.7)
opentelemetry-helpers-sql-obfuscation (0.4.0) opentelemetry-helpers-sql-obfuscation (0.5.0)
opentelemetry-common (~> 0.21) opentelemetry-common (~> 0.21)
opentelemetry-instrumentation-action_mailer (0.6.1) opentelemetry-instrumentation-action_mailer (0.6.1)
opentelemetry-instrumentation-active_support (~> 0.10) opentelemetry-instrumentation-active_support (~> 0.10)
@ -548,7 +551,7 @@ GEM
opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-net_http (0.26.0) opentelemetry-instrumentation-net_http (0.26.0)
opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-pg (0.32.0) opentelemetry-instrumentation-pg (0.33.0)
opentelemetry-helpers-sql opentelemetry-helpers-sql
opentelemetry-helpers-sql-obfuscation opentelemetry-helpers-sql-obfuscation
opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-base (~> 0.25)
@ -581,7 +584,7 @@ GEM
ox (2.14.23) ox (2.14.23)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.9.0) parser (3.3.10.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
parslet (2.0.0) parslet (2.0.0)
@ -590,7 +593,7 @@ GEM
pg (1.6.2) pg (1.6.2)
pghero (3.7.0) pghero (3.7.0)
activerecord (>= 7.1) activerecord (>= 7.1)
playwright-ruby-client (1.55.0) playwright-ruby-client (1.56.0)
concurrent-ruby (>= 1.1.6) concurrent-ruby (>= 1.1.6)
mime-types (>= 3.0) mime-types (>= 3.0)
pp (0.6.3) pp (0.6.3)
@ -604,7 +607,7 @@ GEM
net-smtp net-smtp
premailer (~> 1.7, >= 1.7.9) premailer (~> 1.7, >= 1.7.9)
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.5.2) prism (1.6.0)
prometheus_exporter (2.3.0) prometheus_exporter (2.3.0)
webrick webrick
propshaft (1.3.1) propshaft (1.3.1)
@ -621,7 +624,7 @@ GEM
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.3) rack (3.2.4)
rack-attack (6.8.0) rack-attack (6.8.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-cors (3.0.0) rack-cors (3.0.0)
@ -691,7 +694,7 @@ GEM
readline (~> 0.0) readline (~> 0.0)
rdf-normalize (0.7.0) rdf-normalize (0.7.0)
rdf (~> 3.3) rdf (~> 3.3)
rdoc (6.15.0) rdoc (6.15.1)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
@ -706,9 +709,9 @@ GEM
io-console (~> 0.5) io-console (~> 0.5)
request_store (1.7.0) request_store (1.7.0)
rack (>= 1.4) rack (>= 1.4)
responders (3.1.1) responders (3.2.0)
actionpack (>= 5.2) actionpack (>= 7.0)
railties (>= 5.2) railties (>= 7.0)
rexml (3.4.4) rexml (3.4.4)
rotp (6.3.0) rotp (6.3.0)
rouge (4.6.1) rouge (4.6.1)
@ -745,7 +748,7 @@ GEM
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9) sidekiq (>= 5, < 9)
rspec-support (3.13.6) rspec-support (3.13.6)
rubocop (1.81.6) rubocop (1.81.7)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@ -791,7 +794,7 @@ GEM
ruby-vips (2.2.5) ruby-vips (2.2.5)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
rubyzip (3.2.1) rubyzip (3.2.2)
rufus-scheduler (3.9.2) rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1) fugit (~> 1.1, >= 1.11.1)
safety_net_attestation (0.5.0) safety_net_attestation (0.5.0)
@ -803,9 +806,9 @@ GEM
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
securerandom (0.4.1) securerandom (0.4.1)
shoulda-matchers (6.5.0) shoulda-matchers (7.0.1)
activesupport (>= 5.2.0) activesupport (>= 7.1)
sidekiq (8.0.8) sidekiq (8.0.9)
connection_pool (>= 2.5.0) connection_pool (>= 2.5.0)
json (>= 2.9.0) json (>= 2.9.0)
logger (>= 1.6.2) logger (>= 1.6.2)
@ -822,9 +825,9 @@ GEM
thor (>= 1.0, < 3.0) thor (>= 1.0, < 3.0)
simple-navigation (4.4.0) simple-navigation (4.4.0)
activesupport (>= 2.3.2) activesupport (>= 2.3.2)
simple_form (5.3.1) simple_form (5.4.0)
actionpack (>= 5.2) actionpack (>= 7.0)
activemodel (>= 5.2) activemodel (>= 7.0)
simplecov (0.22.0) simplecov (0.22.0)
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) simplecov-html (~> 0.11)
@ -835,7 +838,7 @@ GEM
stackprof (0.2.27) stackprof (0.2.27)
starry (0.2.0) starry (0.2.0)
base64 base64
stoplight (5.4.0) stoplight (5.5.0)
zeitwerk zeitwerk
stringio (3.1.7) stringio (3.1.7)
strong_migrations (2.5.1) strong_migrations (2.5.1)
@ -883,7 +886,7 @@ GEM
unicode-display_width (3.2.0) unicode-display_width (3.2.0)
unicode-emoji (~> 4.1) unicode-emoji (~> 4.1)
unicode-emoji (4.1.0) unicode-emoji (4.1.0)
uri (1.0.4) uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
validate_url (1.0.15) validate_url (1.0.15)
activemodel (>= 3.0.0) activemodel (>= 3.0.0)
@ -911,7 +914,7 @@ GEM
activesupport activesupport
faraday (~> 2.0) faraday (~> 2.0)
faraday-follow_redirects faraday-follow_redirects
webmock (3.25.1) webmock (3.26.1)
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
@ -933,7 +936,7 @@ DEPENDENCIES
active_model_serializers (~> 0.10) active_model_serializers (~> 0.10)
addressable (~> 2.8) addressable (~> 2.8)
annotaterb (~> 4.13) annotaterb (~> 4.13)
aws-sdk-core (< 3.216.0) aws-sdk-core
aws-sdk-s3 (~> 1.123) aws-sdk-s3 (~> 1.123)
better_errors (~> 2.9) better_errors (~> 2.9)
binding_of_caller (~> 1.0) binding_of_caller (~> 1.0)
@ -1018,7 +1021,7 @@ DEPENDENCIES
opentelemetry-instrumentation-http (~> 0.27.0) opentelemetry-instrumentation-http (~> 0.27.0)
opentelemetry-instrumentation-http_client (~> 0.26.0) opentelemetry-instrumentation-http_client (~> 0.26.0)
opentelemetry-instrumentation-net_http (~> 0.26.0) opentelemetry-instrumentation-net_http (~> 0.26.0)
opentelemetry-instrumentation-pg (~> 0.32.0) opentelemetry-instrumentation-pg (~> 0.33.0)
opentelemetry-instrumentation-rack (~> 0.29.0) opentelemetry-instrumentation-rack (~> 0.29.0)
opentelemetry-instrumentation-rails (~> 0.39.0) opentelemetry-instrumentation-rails (~> 0.39.0)
opentelemetry-instrumentation-redis (~> 0.28.0) opentelemetry-instrumentation-redis (~> 0.28.0)
@ -1028,7 +1031,7 @@ DEPENDENCIES
parslet parslet
pg (~> 1.5) pg (~> 1.5)
pghero pghero
playwright-ruby-client (= 1.55.0) playwright-ruby-client (= 1.56.0)
premailer-rails premailer-rails
prometheus_exporter (~> 2.2) prometheus_exporter (~> 2.2)
propshaft propshaft

View File

@ -15,7 +15,8 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
| Version | Supported | | Version | Supported |
| ------- | ---------------- | | ------- | ---------------- |
| 4.5.x | Yes |
| 4.4.x | Yes | | 4.4.x | Yes |
| 4.3.x | Yes | | 4.3.x | Until 2026-05-06 |
| 4.2.x | Until 2026-01-08 | | 4.2.x | Until 2026-01-08 |
| < 4.2 | No | | < 4.2 | No |

View File

@ -9,7 +9,7 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
before_action :set_quote_authorization before_action :set_quote_authorization
def show def show
expires_in 30.seconds, public: true if @quote.status.distributable? && public_fetch_mode? expires_in 30.seconds, public: true if @quote.quoted_status.distributable? && public_fetch_mode?
render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end end
@ -23,7 +23,7 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
@quote = Quote.accepted.where(quoted_account: @account).find(params[:id]) @quote = Quote.accepted.where(quoted_account: @account).find(params[:id])
return not_found unless @quote.status.present? && @quote.quoted_status.present? return not_found unless @quote.status.present? && @quote.quoted_status.present?
authorize @quote.status, :show? authorize @quote.quoted_status, :show?
rescue Mastodon::NotPermittedError rescue Mastodon::NotPermittedError
not_found not_found
end end

View File

@ -66,7 +66,7 @@ class Api::V1::StatusesController < Api::BaseController
if async_refresh.running? if async_refresh.running?
add_async_refresh_header(async_refresh) add_async_refresh_header(async_refresh)
elsif !current_account.nil? && @status.should_fetch_replies? elsif !current_account.nil? && @status.should_fetch_replies?
add_async_refresh_header(AsyncRefresh.create(refresh_key)) add_async_refresh_header(AsyncRefresh.create(refresh_key, count_results: true))
WorkerBatch.new.within do |batch| WorkerBatch.new.within do |batch|
batch.connect(refresh_key, threshold: 1.0) batch.connect(refresh_key, threshold: 1.0)
@ -126,10 +126,11 @@ class Api::V1::StatusesController < Api::BaseController
@status = Status.where(account: current_account).find(params[:id]) @status = Status.where(account: current_account).find(params[:id])
authorize @status, :destroy? authorize @status, :destroy?
json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true
@status.discard_with_reblogs @status.discard_with_reblogs
StatusPin.find_by(status: @status)&.destroy StatusPin.find_by(status: @status)&.destroy
@status.account.statuses_count = @status.account.statuses_count - 1 @status.account.statuses_count = @status.account.statuses_count - 1
json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true
RemovalWorker.perform_async(@status.id, { 'redraft' => !truthy_param?(:delete_media) }) RemovalWorker.perform_async(@status.id, { 'redraft' => !truthy_param?(:delete_media) })

View File

@ -7,6 +7,7 @@ class FollowerAccountsController < ApplicationController
vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' }
before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :protect_hidden_collections, if: -> { request.format.json? }
skip_around_action :set_locale, if: -> { request.format == :json } skip_around_action :set_locale, if: -> { request.format == :json }
skip_before_action :require_functional!, unless: :limited_federation_mode? skip_before_action :require_functional!, unless: :limited_federation_mode?
@ -18,8 +19,6 @@ class FollowerAccountsController < ApplicationController
end end
format.json do format.json do
raise Mastodon::NotPermittedError if page_requested? && @account.hide_collections?
expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?)
render json: collection_presenter, render json: collection_presenter,
@ -41,6 +40,10 @@ class FollowerAccountsController < ApplicationController
@follows = scope.recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account) @follows = scope.recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
end end
def protect_hidden_collections
raise Mastodon::NotPermittedError if page_requested? && @account.hide_collections?
end
def page_requested? def page_requested?
params[:page].present? params[:page].present?
end end

View File

@ -7,6 +7,7 @@ class FollowingAccountsController < ApplicationController
vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' }
before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :protect_hidden_collections, if: -> { request.format.json? }
skip_around_action :set_locale, if: -> { request.format == :json } skip_around_action :set_locale, if: -> { request.format == :json }
skip_before_action :require_functional!, unless: :limited_federation_mode? skip_before_action :require_functional!, unless: :limited_federation_mode?
@ -18,11 +19,6 @@ class FollowingAccountsController < ApplicationController
end end
format.json do format.json do
if page_requested? && @account.hide_collections?
forbidden
next
end
expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?)
render json: collection_presenter, render json: collection_presenter,
@ -44,6 +40,10 @@ class FollowingAccountsController < ApplicationController
@follows = scope.recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account) @follows = scope.recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
end end
def protect_hidden_collections
raise Mastodon::NotPermittedError if page_requested? && @account.hide_collections?
end
def page_requested? def page_requested?
params[:page].present? params[:page].present?
end end

View File

@ -70,7 +70,7 @@ function loaded() {
}; };
document.querySelectorAll('.emojify').forEach((content) => { document.querySelectorAll('.emojify').forEach((content) => {
content.innerHTML = emojify(content.innerHTML, {}, true); // Force emojify as public doesn't load the new emoji system. content.innerHTML = emojify(content.innerHTML);
}); });
document document

View File

@ -5,6 +5,7 @@ import { throttle } from 'lodash';
import api from 'mastodon/api'; import api from 'mastodon/api';
import { browserHistory } from 'mastodon/components/router'; import { browserHistory } from 'mastodon/components/router';
import { countableText } from 'mastodon/features/compose/util/counter';
import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light'; import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light';
import { tagHistory } from 'mastodon/settings'; import { tagHistory } from 'mastodon/settings';
@ -55,7 +56,6 @@ export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE'; export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
@ -88,6 +88,7 @@ const messages = defineMessages({
open: { id: 'compose.published.open', defaultMessage: 'Open' }, open: { id: 'compose.published.open', defaultMessage: 'Open' },
published: { id: 'compose.published.body', defaultMessage: 'Post published.' }, published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' }, saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
blankPostError: { id: 'compose.error.blank_post', defaultMessage: 'Post can\'t be blank.' },
}); });
export const ensureComposeIsVisible = (getState) => { export const ensureComposeIsVisible = (getState) => {
@ -197,7 +198,15 @@ export function submitCompose(successCallback) {
const hasQuote = !!getState().getIn(['compose', 'quoted_status_id']); const hasQuote = !!getState().getIn(['compose', 'quoted_status_id']);
const spoiler_text = getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : ''; const spoiler_text = getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '';
if (!(status?.length || media.size !== 0 || (hasQuote && spoiler_text?.length))) { const fulltext = `${spoiler_text ?? ''}${countableText(status ?? '')}`;
const hasText = fulltext.trim().length > 0;
if (!(hasText || media.size !== 0 || (hasQuote && spoiler_text?.length))) {
dispatch(showAlert({
message: messages.blankPostError,
}));
dispatch(focusCompose());
return; return;
} }
@ -784,13 +793,6 @@ export function changeComposeSpoilerText(text) {
}; };
} }
export function changeComposeVisibility(value) {
return {
type: COMPOSE_VISIBILITY_CHANGE,
value,
};
}
export function insertEmojiCompose(position, emoji, needsSpace) { export function insertEmojiCompose(position, emoji, needsSpace) {
return { return {
type: COMPOSE_EMOJI_INSERT, type: COMPOSE_EMOJI_INSERT,

View File

@ -13,10 +13,11 @@ import {
} from 'mastodon/store/typed_functions'; } from 'mastodon/store/typed_functions';
import type { ApiQuotePolicy } from '../api_types/quotes'; import type { ApiQuotePolicy } from '../api_types/quotes';
import type { Status } from '../models/status'; import type { Status, StatusVisibility } from '../models/status';
import type { RootState } from '../store';
import { showAlert } from './alerts'; import { showAlert } from './alerts';
import { focusCompose } from './compose'; import { changeCompose, focusCompose } from './compose';
import { importFetchedStatuses } from './importer'; import { importFetchedStatuses } from './importer';
import { openModal } from './modal'; import { openModal } from './modal';
@ -41,6 +42,10 @@ const messages = defineMessages({
id: 'quote_error.unauthorized', id: 'quote_error.unauthorized',
defaultMessage: 'You are not authorized to quote this post.', defaultMessage: 'You are not authorized to quote this post.',
}, },
quoteErrorPrivateMention: {
id: 'quote_error.private_mentions',
defaultMessage: 'Quoting is not allowed with direct mentions.',
},
}); });
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & { type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
@ -67,6 +72,39 @@ const simulateModifiedApiResponse = (
return data; return data;
}; };
export const changeComposeVisibility = createAppThunk(
'compose/visibility_change',
(visibility: StatusVisibility, { dispatch, getState }) => {
if (visibility !== 'direct') {
return visibility;
}
const state = getState();
const quotedStatusId = state.compose.get('quoted_status_id') as
| string
| null;
if (!quotedStatusId) {
return visibility;
}
// Remove the quoted status
dispatch(quoteComposeCancel());
const quotedStatus = state.statuses.get(quotedStatusId) as Status | null;
if (!quotedStatus) {
return visibility;
}
// Append the quoted status URL to the compose text
const url = quotedStatus.get('url') as string;
const text = state.compose.get('text') as string;
if (!text.includes(url)) {
const newText = text.trim() ? `${text}\n\n${url}` : url;
dispatch(changeCompose(newText));
}
return visibility;
},
);
export const changeUploadCompose = createDataLoadingThunk( export const changeUploadCompose = createDataLoadingThunk(
'compose/changeUpload', 'compose/changeUpload',
async ( async (
@ -130,6 +168,8 @@ export const quoteComposeByStatus = createAppThunk(
if (composeState.get('id')) { if (composeState.get('id')) {
dispatch(showAlert({ message: messages.quoteErrorEdit })); dispatch(showAlert({ message: messages.quoteErrorEdit }));
} else if (composeState.get('privacy') === 'direct') {
dispatch(showAlert({ message: messages.quoteErrorPrivateMention }));
} else if (composeState.get('poll')) { } else if (composeState.get('poll')) {
dispatch(showAlert({ message: messages.quoteErrorPoll })); dispatch(showAlert({ message: messages.quoteErrorPoll }));
} else if ( } else if (
@ -173,6 +213,17 @@ export const quoteComposeById = createAppThunk(
}, },
); );
const composeStateForbidsLink = (composeState: RootState['compose']) => {
return (
composeState.get('quoted_status_id') ||
composeState.get('is_submitting') ||
composeState.get('poll') ||
composeState.get('is_uploading') ||
composeState.get('id') ||
composeState.get('privacy') === 'direct'
);
};
export const pasteLinkCompose = createDataLoadingThunk( export const pasteLinkCompose = createDataLoadingThunk(
'compose/pasteLink', 'compose/pasteLink',
async ({ url }: { url: string }) => { async ({ url }: { url: string }) => {
@ -183,15 +234,12 @@ export const pasteLinkCompose = createDataLoadingThunk(
limit: 2, limit: 2,
}); });
}, },
(data, { dispatch, getState }) => { (data, { dispatch, getState, requestId }) => {
const composeState = getState().compose; const composeState = getState().compose;
if ( if (
composeState.get('quoted_status_id') || composeStateForbidsLink(composeState) ||
composeState.get('is_submitting') || composeState.get('fetching_link') !== requestId // Request has been cancelled
composeState.get('poll') ||
composeState.get('is_uploading') ||
composeState.get('id')
) )
return; return;
@ -207,6 +255,17 @@ export const pasteLinkCompose = createDataLoadingThunk(
dispatch(quoteComposeById(data.statuses[0].id)); dispatch(quoteComposeById(data.statuses[0].id));
} }
}, },
{
useLoadingBar: false,
condition: (_, { getState }) =>
!getState().compose.get('fetching_link') &&
!composeStateForbidsLink(getState().compose),
},
);
// Ideally this would cancel the action and the HTTP request, but this is good enough
export const cancelPasteLinkCompose = createAction(
'compose/cancelPasteLinkCompose',
); );
export const quoteComposeCancel = createAction('compose/quoteComposeCancel'); export const quoteComposeCancel = createAction('compose/quoteComposeCancel');

View File

@ -1,8 +1,5 @@
import escapeTextContentForBrowser from 'escape-html'; import escapeTextContentForBrowser from 'escape-html';
import { makeEmojiMap } from 'mastodon/models/custom_emoji';
import emojify from '../../features/emoji/emoji';
import { expandSpoilers } from '../../initial_state'; import { expandSpoilers } from '../../initial_state';
const domParser = new DOMParser(); const domParser = new DOMParser();
@ -88,11 +85,10 @@ export function normalizeStatus(status, normalOldStatus) {
const spoilerText = normalStatus.spoiler_text || ''; const spoilerText = normalStatus.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const emojiMap = makeEmojiMap(normalStatus.emojis);
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); normalStatus.contentHtml = normalStatus.content;
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); normalStatus.spoilerHtml = escapeTextContentForBrowser(spoilerText);
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins // Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
@ -128,14 +124,12 @@ export function normalizeStatus(status, normalOldStatus) {
} }
export function normalizeStatusTranslation(translation, status) { export function normalizeStatusTranslation(translation, status) {
const emojiMap = makeEmojiMap(status.get('emojis').toJS());
const normalTranslation = { const normalTranslation = {
detected_source_language: translation.detected_source_language, detected_source_language: translation.detected_source_language,
language: translation.language, language: translation.language,
provider: translation.provider, provider: translation.provider,
contentHtml: emojify(translation.content, emojiMap), contentHtml: translation.content,
spoilerHtml: emojify(escapeTextContentForBrowser(translation.spoiler_text), emojiMap), spoilerHtml: escapeTextContentForBrowser(translation.spoiler_text),
spoiler_text: translation.spoiler_text, spoiler_text: translation.spoiler_text,
}; };
@ -149,9 +143,8 @@ export function normalizeStatusTranslation(translation, status) {
export function normalizeAnnouncement(announcement) { export function normalizeAnnouncement(announcement) {
const normalAnnouncement = { ...announcement }; const normalAnnouncement = { ...announcement };
const emojiMap = makeEmojiMap(normalAnnouncement.emojis);
normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap); normalAnnouncement.contentHtml = normalAnnouncement.content;
return normalAnnouncement; return normalAnnouncement;
} }

View File

@ -32,13 +32,20 @@ import {
const randomUpTo = max => const randomUpTo = max =>
Math.floor(Math.random() * Math.floor(max)); Math.floor(Math.random() * Math.floor(max));
/**
* @typedef {import('mastodon/store').AppDispatch} Dispatch
* @typedef {import('mastodon/store').GetState} GetState
* @typedef {import('redux').UnknownAction} UnknownAction
* @typedef {function(Dispatch, GetState): Promise<void>} FallbackFunction
*/
/** /**
* @param {string} timelineId * @param {string} timelineId
* @param {string} channelName * @param {string} channelName
* @param {Object.<string, string>} params * @param {Object.<string, string>} params
* @param {Object} options * @param {Object} options
* @param {function(Function, Function): Promise<void>} [options.fallback] * @param {FallbackFunction} [options.fallback]
* @param {function(): void} [options.fillGaps] * @param {function(): UnknownAction} [options.fillGaps]
* @param {function(object): boolean} [options.accept] * @param {function(object): boolean} [options.accept]
* @returns {function(): void} * @returns {function(): void}
*/ */
@ -46,13 +53,14 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
const { messages } = getLocale(); const { messages } = getLocale();
return connectStream(channelName, params, (dispatch, getState) => { return connectStream(channelName, params, (dispatch, getState) => {
// @ts-ignore
const locale = getState().getIn(['meta', 'locale']); const locale = getState().getIn(['meta', 'locale']);
// @ts-expect-error // @ts-expect-error
let pollingId; let pollingId;
/** /**
* @param {function(Function, Function): Promise<void>} fallback * @param {FallbackFunction} fallback
*/ */
const useFallback = async fallback => { const useFallback = async fallback => {
@ -132,7 +140,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
}; };
/** /**
* @param {Function} dispatch * @param {Dispatch} dispatch
*/ */
async function refreshHomeTimelineAndNotification(dispatch) { async function refreshHomeTimelineAndNotification(dispatch) {
await dispatch(expandHomeTimeline({ maxId: undefined })); await dispatch(expandHomeTimeline({ maxId: undefined }));
@ -151,7 +159,11 @@ async function refreshHomeTimelineAndNotification(dispatch) {
* @returns {function(): void} * @returns {function(): void}
*/ */
export const connectUserStream = () => export const connectUserStream = () =>
connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification, fillGaps: fillHomeTimelineGaps }); connectTimelineStream('home', 'user', {}, {
fallback: refreshHomeTimelineAndNotification,
// @ts-expect-error
fillGaps: fillHomeTimelineGaps
});
/** /**
* @param {Object} options * @param {Object} options
@ -159,7 +171,10 @@ export const connectUserStream = () =>
* @returns {function(): void} * @returns {function(): void}
*/ */
export const connectCommunityStream = ({ onlyMedia } = {}) => export const connectCommunityStream = ({ onlyMedia } = {}) =>
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) }); connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, {
// @ts-expect-error
fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia }))
});
/** /**
* @param {Object} options * @param {Object} options
@ -168,7 +183,10 @@ export const connectCommunityStream = ({ onlyMedia } = {}) =>
* @returns {function(): void} * @returns {function(): void}
*/ */
export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) => export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) =>
connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote }) }); connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, {}, {
// @ts-expect-error
fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote })
});
/** /**
* @param {string} columnId * @param {string} columnId
@ -191,4 +209,7 @@ export const connectDirectStream = () =>
* @returns {function(): void} * @returns {function(): void}
*/ */
export const connectListStream = listId => export const connectListStream = listId =>
connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { fillGaps: () => fillListTimelineGaps(listId) }); connectTimelineStream(`list:${listId}`, 'list', { list: listId }, {
// @ts-expect-error
fillGaps: () => fillListTimelineGaps(listId)
});

View File

@ -1,11 +1,6 @@
import { useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useLinks } from 'mastodon/hooks/useLinks';
import { useAppSelector } from '../store'; import { useAppSelector } from '../store';
import { isModernEmojiEnabled } from '../utils/environment';
import { EmojiHTML } from './emoji/html'; import { EmojiHTML } from './emoji/html';
import { useElementHandledLink } from './status/handled_link'; import { useElementHandledLink } from './status/handled_link';
@ -21,22 +16,6 @@ export const AccountBio: React.FC<AccountBioProps> = ({
accountId, accountId,
showDropdown = false, showDropdown = false,
}) => { }) => {
const handleClick = useLinks(showDropdown);
const handleNodeChange = useCallback(
(node: HTMLDivElement | null) => {
if (
!showDropdown ||
!node ||
node.childNodes.length === 0 ||
isModernEmojiEnabled()
) {
return;
}
addDropdownToHashtags(node, accountId);
},
[showDropdown, accountId],
);
const htmlHandlers = useElementHandledLink({ const htmlHandlers = useElementHandledLink({
hashtagAccountId: showDropdown ? accountId : undefined, hashtagAccountId: showDropdown ? accountId : undefined,
}); });
@ -62,30 +41,7 @@ export const AccountBio: React.FC<AccountBioProps> = ({
htmlString={note} htmlString={note}
extraEmojis={extraEmojis} extraEmojis={extraEmojis}
className={classNames(className, 'translate')} className={classNames(className, 'translate')}
onClickCapture={handleClick}
ref={handleNodeChange}
{...htmlHandlers} {...htmlHandlers}
/> />
); );
}; };
function addDropdownToHashtags(node: HTMLElement | null, accountId: string) {
if (!node) {
return;
}
for (const childNode of node.childNodes) {
if (!(childNode instanceof HTMLElement)) {
continue;
}
if (
childNode instanceof HTMLAnchorElement &&
(childNode.classList.contains('hashtag') ||
childNode.innerText.startsWith('#')) &&
!childNode.dataset.menuHashtag
) {
childNode.dataset.menuHashtag = accountId;
} else if (childNode.childNodes.length > 0) {
addDropdownToHashtags(childNode, accountId);
}
}
}

View File

@ -49,7 +49,11 @@ export const Alert: React.FC<{
</span> </span>
{hasAction && ( {hasAction && (
<button className='notification-bar__action' onClick={onActionClick}> <button
className='notification-bar__action'
onClick={onActionClick}
type='button'
>
{action} {action}
</button> </button>
)} )}

View File

@ -47,7 +47,7 @@ export const AltTextBadge: React.FC<{ description: string }> = ({
rootClose rootClose
onHide={handleClose} onHide={handleClose}
show={open} show={open}
target={anchorRef.current} target={anchorRef}
placement='top-end' placement='top-end'
flip flip
offset={offset} offset={offset}

View File

@ -78,6 +78,7 @@ export const Button: React.FC<Props> = ({
aria-live={loading !== undefined ? 'polite' : undefined} aria-live={loading !== undefined ? 'polite' : undefined}
onClick={handleClick} onClick={handleClick}
title={title} title={title}
// eslint-disable-next-line react/button-has-type -- set correctly via TS
type={type} type={type}
{...props} {...props}
> >

View File

@ -0,0 +1,126 @@
import type { FC } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn, userEvent, expect } from 'storybook/test';
import type { CarouselProps } from './index';
import { Carousel } from './index';
interface TestSlideProps {
id: number;
text: string;
color: string;
}
const TestSlide: FC<TestSlideProps & { active: boolean }> = ({
active,
text,
color,
}) => (
<div
className='test-slide'
style={{
backgroundColor: active ? color : undefined,
}}
>
{text}
</div>
);
const slides: TestSlideProps[] = [
{
id: 1,
text: 'first',
color: 'red',
},
{
id: 2,
text: 'second',
color: 'pink',
},
{
id: 3,
text: 'third',
color: 'orange',
},
];
type StoryProps = Pick<
CarouselProps<TestSlideProps>,
'items' | 'renderItem' | 'emptyFallback' | 'onChangeSlide'
>;
const meta = {
title: 'Components/Carousel',
args: {
items: slides,
renderItem(item, active) {
return <TestSlide {...item} active={active} key={item.id} />;
},
onChangeSlide: fn(),
emptyFallback: 'No slides available',
},
render(args) {
return (
<>
<Carousel {...args} />
<style>
{`.test-slide {
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
font-weight: bold;
min-height: 100px;
transition: background-color 0.3s;
background-color: black;
}`}
</style>
</>
);
},
argTypes: {
emptyFallback: {
type: 'string',
},
},
tags: ['test'],
} satisfies Meta<StoryProps>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
async play({ args, canvas }) {
const nextButton = await canvas.findByRole('button', { name: /next/i });
const slides = await canvas.findAllByRole('group');
await expect(slides).toHaveLength(slides.length);
await userEvent.click(nextButton);
await expect(args.onChangeSlide).toHaveBeenCalledWith(1, slides[1]);
await userEvent.click(nextButton);
await expect(args.onChangeSlide).toHaveBeenCalledWith(2, slides[2]);
// Wrap around
await userEvent.click(nextButton);
await expect(args.onChangeSlide).toHaveBeenCalledWith(0, slides[0]);
},
};
export const DifferentHeights: Story = {
args: {
items: slides.map((props, index) => ({
...props,
styles: { height: 100 + index * 100 },
})),
},
};
export const NoSlides: Story = {
args: {
items: [],
},
};

View File

@ -0,0 +1,244 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type {
ComponentPropsWithoutRef,
ComponentType,
ReactElement,
ReactNode,
} from 'react';
import type { MessageDescriptor } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { usePrevious } from '@dnd-kit/utilities';
import { animated, useSpring } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import type { CarouselPaginationProps } from './pagination';
import { CarouselPagination } from './pagination';
import './styles.scss';
const defaultMessages = defineMessages({
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
current: {
id: 'carousel.current',
defaultMessage: '<sr>Slide</sr> {current, number} / {max, number}',
},
slide: {
id: 'carousel.slide',
defaultMessage: 'Slide {current, number} of {max, number}',
},
});
export type MessageKeys = keyof typeof defaultMessages;
export interface CarouselSlideProps {
id: string | number;
}
export type RenderSlideFn<
SlideProps extends CarouselSlideProps = CarouselSlideProps,
> = (item: SlideProps, active: boolean, index: number) => ReactElement;
export interface CarouselProps<
SlideProps extends CarouselSlideProps = CarouselSlideProps,
> {
items: SlideProps[];
renderItem: RenderSlideFn<SlideProps>;
onChangeSlide?: (index: number, ref: Element) => void;
paginationComponent?: ComponentType<CarouselPaginationProps> | null;
paginationProps?: Partial<CarouselPaginationProps>;
messages?: Record<MessageKeys, MessageDescriptor>;
emptyFallback?: ReactNode;
classNamePrefix?: string;
slideClassName?: string;
}
export const Carousel = <
SlideProps extends CarouselSlideProps = CarouselSlideProps,
>({
items,
renderItem,
onChangeSlide,
paginationComponent: Pagination = CarouselPagination,
paginationProps = {},
messages = defaultMessages,
children,
emptyFallback = null,
className,
classNamePrefix = 'carousel',
slideClassName,
...wrapperProps
}: CarouselProps<SlideProps> & ComponentPropsWithoutRef<'div'>) => {
// Handle slide change
const [slideIndex, setSlideIndex] = useState(0);
const wrapperRef = useRef<HTMLDivElement>(null);
// Handle slide heights
const [currentSlideHeight, setCurrentSlideHeight] = useState(
() => wrapperRef.current?.scrollHeight ?? 0,
);
const previousSlideHeight = usePrevious(currentSlideHeight);
const handleSlideChange = useCallback(
(direction: number) => {
setSlideIndex((prev) => {
const max = items.length - 1;
let newIndex = prev + direction;
if (newIndex < 0) {
newIndex = max;
} else if (newIndex > max) {
newIndex = 0;
}
const slide = wrapperRef.current?.children[newIndex];
if (slide) {
setCurrentSlideHeight(slide.scrollHeight);
if (slide instanceof HTMLElement) {
onChangeSlide?.(newIndex, slide);
}
}
return newIndex;
});
},
[items.length, onChangeSlide],
);
const observerRef = useRef<ResizeObserver | null>(null);
observerRef.current ??= new ResizeObserver(() => {
handleSlideChange(0);
});
const wrapperStyles = useSpring({
x: `-${slideIndex * 100}%`,
height: currentSlideHeight,
// Don't animate from zero to the height of the initial slide
immediate: !previousSlideHeight,
});
useLayoutEffect(() => {
// Update slide height when the component mounts
if (currentSlideHeight === 0) {
handleSlideChange(0);
}
}, [currentSlideHeight, handleSlideChange]);
// Handle swiping animations
const bind = useDrag(
({ swipe: [swipeX] }) => {
handleSlideChange(swipeX * -1); // Invert swipe as swiping left loads the next slide.
},
{ pointer: { capture: false } },
);
const handlePrev = useCallback(() => {
handleSlideChange(-1);
// We're focusing on the wrapper as the child slides can potentially be inert.
// Because of that, only the active slide can be focused anyway.
wrapperRef.current?.focus();
}, [handleSlideChange]);
const handleNext = useCallback(() => {
handleSlideChange(1);
wrapperRef.current?.focus();
}, [handleSlideChange]);
const intl = useIntl();
if (items.length === 0) {
return emptyFallback;
}
return (
<div
{...bind()}
aria-roledescription='carousel'
role='region'
className={classNames(classNamePrefix, className)}
{...wrapperProps}
>
<div className={`${classNamePrefix}__header`}>
{children}
{Pagination && items.length > 1 && (
<Pagination
current={slideIndex}
max={items.length}
onNext={handleNext}
onPrev={handlePrev}
className={`${classNamePrefix}__pagination`}
messages={messages}
{...paginationProps}
/>
)}
</div>
<animated.div
className={`${classNamePrefix}__slides`}
ref={wrapperRef}
style={wrapperStyles}
aria-label={intl.formatMessage(messages.slide, {
current: slideIndex + 1,
max: items.length,
})}
tabIndex={-1}
>
{items.map((itemsProps, index) => (
<CarouselSlideWrapper<SlideProps>
item={itemsProps}
renderItem={renderItem}
observer={observerRef.current}
index={index}
key={`slide-${itemsProps.id}`}
className={classNames(`${classNamePrefix}__slide`, slideClassName, {
active: index === slideIndex,
})}
active={index === slideIndex}
/>
))}
</animated.div>
</div>
);
};
type CarouselSlideWrapperProps<SlideProps extends CarouselSlideProps> = {
observer: ResizeObserver | null;
className: string;
active: boolean;
item: SlideProps;
index: number;
} & Pick<CarouselProps<SlideProps>, 'renderItem'>;
const CarouselSlideWrapper = <SlideProps extends CarouselSlideProps>({
observer,
className,
active,
renderItem,
item,
index,
}: CarouselSlideWrapperProps<SlideProps>) => {
const handleRef = useCallback(
(instance: HTMLDivElement | null) => {
if (observer && instance) {
observer.observe(instance);
}
},
[observer],
);
const children = useMemo(
() => renderItem(item, active, index),
[renderItem, item, active, index],
);
return (
<div
ref={handleRef}
className={className}
role='group'
aria-roledescription='slide'
inert={active ? undefined : ''}
data-index={index}
>
{children}
</div>
);
};

View File

@ -0,0 +1,54 @@
import type { FC, MouseEventHandler } from 'react';
import type { MessageDescriptor } from 'react-intl';
import { useIntl } from 'react-intl';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import { IconButton } from '../icon_button';
import type { MessageKeys } from './index';
export interface CarouselPaginationProps {
onNext: MouseEventHandler;
onPrev: MouseEventHandler;
current: number;
max: number;
className?: string;
messages: Record<MessageKeys, MessageDescriptor>;
}
export const CarouselPagination: FC<CarouselPaginationProps> = ({
onNext,
onPrev,
current,
max,
className = '',
messages,
}) => {
const intl = useIntl();
return (
<div className={className}>
<IconButton
title={intl.formatMessage(messages.previous)}
icon='chevron-left'
iconComponent={ChevronLeftIcon}
onClick={onPrev}
/>
<span aria-live='polite'>
{intl.formatMessage(messages.current, {
current: current + 1,
max,
sr: (chunk) => <span className='sr-only'>{chunk}</span>,
})}
</span>
<IconButton
title={intl.formatMessage(messages.next)}
icon='chevron-right'
iconComponent={ChevronRightIcon}
onClick={onNext}
/>
</div>
);
};

View File

@ -0,0 +1,28 @@
.carousel {
gap: 16px;
overflow: hidden;
touch-action: pan-y;
&__header {
padding: 8px 16px;
}
&__pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
&__slides {
display: flex;
flex-wrap: nowrap;
align-items: start;
}
&__slide {
flex: 0 0 100%;
width: 100%;
overflow: hidden;
}
}

View File

@ -30,7 +30,7 @@ export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({
const handleClick = useHandleClick(onClick); const handleClick = useHandleClick(onClick);
const component = ( const component = (
<button onClick={handleClick} className='column-back-button'> <button onClick={handleClick} className='column-back-button' type='button'>
<Icon <Icon
id='chevron-left' id='chevron-left'
icon={ArrowBackIcon} icon={ArrowBackIcon}

View File

@ -53,6 +53,7 @@ const BackButton: React.FC<{
compact: onlyIcon, compact: onlyIcon,
})} })}
aria-label={intl.formatMessage(messages.back)} aria-label={intl.formatMessage(messages.back)}
type='button'
> >
<Icon <Icon
id='chevron-left' id='chevron-left'
@ -172,6 +173,7 @@ export const ColumnHeader: React.FC<Props> = ({
<button <button
className='text-btn column-header__setting-btn' className='text-btn column-header__setting-btn'
onClick={handlePin} onClick={handlePin}
type='button'
> >
<Icon id='times' icon={CloseIcon} />{' '} <Icon id='times' icon={CloseIcon} />{' '}
<FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' />
@ -185,6 +187,7 @@ export const ColumnHeader: React.FC<Props> = ({
aria-label={intl.formatMessage(messages.moveLeft)} aria-label={intl.formatMessage(messages.moveLeft)}
className='icon-button column-header__setting-btn' className='icon-button column-header__setting-btn'
onClick={handleMoveLeft} onClick={handleMoveLeft}
type='button'
> >
<Icon id='chevron-left' icon={ChevronLeftIcon} /> <Icon id='chevron-left' icon={ChevronLeftIcon} />
</button> </button>
@ -193,6 +196,7 @@ export const ColumnHeader: React.FC<Props> = ({
aria-label={intl.formatMessage(messages.moveRight)} aria-label={intl.formatMessage(messages.moveRight)}
className='icon-button column-header__setting-btn' className='icon-button column-header__setting-btn'
onClick={handleMoveRight} onClick={handleMoveRight}
type='button'
> >
<Icon id='chevron-right' icon={ChevronRightIcon} /> <Icon id='chevron-right' icon={ChevronRightIcon} />
</button> </button>
@ -203,6 +207,7 @@ export const ColumnHeader: React.FC<Props> = ({
<button <button
className='text-btn column-header__setting-btn' className='text-btn column-header__setting-btn'
onClick={handlePin} onClick={handlePin}
type='button'
> >
<Icon id='plus' icon={AddIcon} />{' '} <Icon id='plus' icon={AddIcon} />{' '}
<FormattedMessage id='column_header.pin' defaultMessage='Pin' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' />
@ -237,6 +242,7 @@ export const ColumnHeader: React.FC<Props> = ({
collapsed ? messages.show : messages.hide, collapsed ? messages.show : messages.hide,
)} )}
onClick={handleToggleClick} onClick={handleToggleClick}
type='button'
> >
<i className='icon-with-badge'> <i className='icon-with-badge'>
<Icon <Icon
@ -259,7 +265,11 @@ export const ColumnHeader: React.FC<Props> = ({
<> <>
{backButton} {backButton}
<button onClick={handleTitleClick} className='column-header__title'> <button
onClick={handleTitleClick}
className='column-header__title'
type='button'
>
{!backButton && ( {!backButton && (
<Icon <Icon
id={icon} id={icon}

View File

@ -1,4 +1,4 @@
import { useCallback, useState, useEffect, useRef } from 'react'; import { useCallback, useState, useRef } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
@ -12,11 +12,15 @@ export const ColumnSearchHeader: React.FC<{
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(''); const [value, setValue] = useState('');
useEffect(() => { // Reset the component when it turns from active to inactive.
// [More on this pattern](https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)
const [previousActive, setPreviousActive] = useState(active);
if (active !== previousActive) {
setPreviousActive(active);
if (!active) { if (!active) {
setValue(''); setValue('');
} }
}, [active]); }
const handleChange = useCallback( const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => { ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {

View File

@ -74,7 +74,7 @@ export const CopyPasteText: React.FC<{ value: string }> = ({ value }) => {
onBlur={handleBlur} onBlur={handleBlur}
/> />
<button className='button' onClick={handleButtonClick}> <button className='button' onClick={handleButtonClick} type='button'>
<Icon id='copy' icon={ContentCopyIcon} />{' '} <Icon id='copy' icon={ContentCopyIcon} />{' '}
{copied ? ( {copied ? (
<FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> <FormattedMessage id='copypaste.copied' defaultMessage='Copied' />

View File

@ -74,6 +74,6 @@ export const Linked: Story = {
acct: username, acct: username,
}) })
: undefined; : undefined;
return <LinkedDisplayName {...args} displayProps={{ account }} />; return <LinkedDisplayName displayProps={{ account, ...args }} />;
}, },
}; };

View File

@ -9,9 +9,8 @@ import { Skeleton } from '../skeleton';
import type { DisplayNameProps } from './index'; import type { DisplayNameProps } from './index';
export const DisplayNameWithoutDomain: FC< export const DisplayNameWithoutDomain: FC<
Omit<DisplayNameProps, 'variant' | 'localDomain'> & Omit<DisplayNameProps, 'variant'> & ComponentPropsWithoutRef<'span'>
ComponentPropsWithoutRef<'span'> > = ({ account, className, children, localDomain: _, ...props }) => {
> = ({ account, className, children, ...props }) => {
return ( return (
<AnimateEmojiProvider <AnimateEmojiProvider
{...props} {...props}

View File

@ -5,9 +5,8 @@ import { EmojiHTML } from '../emoji/html';
import type { DisplayNameProps } from './index'; import type { DisplayNameProps } from './index';
export const DisplayNameSimple: FC< export const DisplayNameSimple: FC<
Omit<DisplayNameProps, 'variant' | 'localDomain'> & Omit<DisplayNameProps, 'variant'> & ComponentPropsWithoutRef<'span'>
ComponentPropsWithoutRef<'span'> > = ({ account, localDomain: _, ...props }) => {
> = ({ account, ...props }) => {
if (!account) { if (!account) {
return null; return null;
} }

View File

@ -109,7 +109,7 @@ export const Dropdown: FC<
placement='bottom-start' placement='bottom-start'
onHide={handleClose} onHide={handleClose}
flip flip
target={buttonRef.current} target={buttonRef}
popperConfig={{ popperConfig={{
strategy: 'fixed', strategy: 'fixed',
modifiers: [matchWidth], modifiers: [matchWidth],

View File

@ -42,16 +42,10 @@ import { IconButton } from './icon_button';
let id = 0; let id = 0;
export interface RenderItemFnHandlers {
onClick: React.MouseEventHandler;
onKeyUp: React.KeyboardEventHandler;
}
export type RenderItemFn<Item = MenuItem> = ( export type RenderItemFn<Item = MenuItem> = (
item: Item, item: Item,
index: number, index: number,
handlers: RenderItemFnHandlers, onClick: React.MouseEventHandler,
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void,
) => React.ReactNode; ) => React.ReactNode;
type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void; type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
@ -101,7 +95,6 @@ export const DropdownMenu = <Item = MenuItem,>({
onItemClick, onItemClick,
}: DropdownMenuProps<Item>) => { }: DropdownMenuProps<Item>) => {
const nodeRef = useRef<HTMLDivElement>(null); const nodeRef = useRef<HTMLDivElement>(null);
const focusedItemRef = useRef<HTMLElement | null>(null);
useEffect(() => { useEffect(() => {
const handleDocumentClick = (e: MouseEvent) => { const handleDocumentClick = (e: MouseEvent) => {
@ -163,8 +156,11 @@ export const DropdownMenu = <Item = MenuItem,>({
document.addEventListener('click', handleDocumentClick, { capture: true }); document.addEventListener('click', handleDocumentClick, { capture: true });
document.addEventListener('keydown', handleKeyDown, { capture: true }); document.addEventListener('keydown', handleKeyDown, { capture: true });
if (focusedItemRef.current && openedViaKeyboard) { if (openedViaKeyboard) {
focusedItemRef.current.focus({ preventScroll: true }); const firstMenuItem = nodeRef.current?.querySelector<
HTMLAnchorElement | HTMLButtonElement
>('li:first-child > :is(a, button)');
firstMenuItem?.focus({ preventScroll: true });
} }
return () => { return () => {
@ -175,13 +171,6 @@ export const DropdownMenu = <Item = MenuItem,>({
}; };
}, [onClose, openedViaKeyboard]); }, [onClose, openedViaKeyboard]);
const handleFocusedItemRef = useCallback(
(c: HTMLAnchorElement | HTMLButtonElement | null) => {
focusedItemRef.current = c as HTMLElement;
},
[],
);
const handleItemClick = useCallback( const handleItemClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => { (e: React.MouseEvent | React.KeyboardEvent) => {
const i = Number(e.currentTarget.getAttribute('data-index')); const i = Number(e.currentTarget.getAttribute('data-index'));
@ -207,15 +196,6 @@ export const DropdownMenu = <Item = MenuItem,>({
[onClose, onItemClick, items], [onClose, onItemClick, items],
); );
const handleItemKeyUp = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
handleItemClick(e);
}
},
[handleItemClick],
);
const nativeRenderItem = (option: Item, i: number) => { const nativeRenderItem = (option: Item, i: number) => {
if (!isMenuItem(option)) { if (!isMenuItem(option)) {
return null; return null;
@ -232,11 +212,10 @@ export const DropdownMenu = <Item = MenuItem,>({
if (isActionItem(option)) { if (isActionItem(option)) {
element = ( element = (
<button <button
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick} onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i} data-index={i}
aria-disabled={disabled} aria-disabled={disabled}
type='button'
> >
<DropdownMenuItemContent item={option} /> <DropdownMenuItemContent item={option} />
</button> </button>
@ -248,9 +227,7 @@ export const DropdownMenu = <Item = MenuItem,>({
target={option.target ?? '_target'} target={option.target ?? '_target'}
data-method={option.method} data-method={option.method}
rel='noopener' rel='noopener'
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick} onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i} data-index={i}
> >
<DropdownMenuItemContent item={option} /> <DropdownMenuItemContent item={option} />
@ -258,13 +235,7 @@ export const DropdownMenu = <Item = MenuItem,>({
); );
} else { } else {
element = ( element = (
<Link <Link to={option.to} onClick={handleItemClick} data-index={i}>
to={option.to}
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
>
<DropdownMenuItemContent item={option} /> <DropdownMenuItemContent item={option} />
</Link> </Link>
); );
@ -307,15 +278,7 @@ export const DropdownMenu = <Item = MenuItem,>({
})} })}
> >
{items.map((option, i) => {items.map((option, i) =>
renderItemMethod( renderItemMethod(option, i, handleItemClick),
option,
i,
{
onClick: handleItemClick,
onKeyUp: handleItemKeyUp,
},
i === 0 ? handleFocusedItemRef : undefined,
),
)} )}
</ul> </ul>
)} )}
@ -399,7 +362,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
}, [dispatch, currentId]); }, [dispatch, currentId]);
const handleItemClick = useCallback( const handleItemClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => { (e: React.MouseEvent) => {
const i = Number(e.currentTarget.getAttribute('data-index')); const i = Number(e.currentTarget.getAttribute('data-index'));
const item = items?.[i]; const item = items?.[i];
@ -420,10 +383,20 @@ export const Dropdown = <Item extends object | null = MenuItem>({
[handleClose, onItemClick, items], [handleClose, onItemClick, items],
); );
const toggleDropdown = useCallback( const isKeypressRef = useRef(false);
(e: React.MouseEvent | React.KeyboardEvent) => {
const { type } = e;
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') {
isKeypressRef.current = true;
}
}, []);
const unsetIsKeypress = useCallback(() => {
isKeypressRef.current = false;
}, []);
const toggleDropdown = useCallback(
(e: React.MouseEvent) => {
if (open) { if (open) {
handleClose(); handleClose();
} else { } else {
@ -450,10 +423,11 @@ export const Dropdown = <Item extends object | null = MenuItem>({
dispatch( dispatch(
openDropdownMenu({ openDropdownMenu({
id: currentId, id: currentId,
keyboard: type !== 'click', keyboard: isKeypressRef.current,
scrollKey, scrollKey,
}), }),
); );
isKeypressRef.current = false;
} }
} }
}, },
@ -484,6 +458,9 @@ export const Dropdown = <Item extends object | null = MenuItem>({
const buttonProps = { const buttonProps = {
disabled, disabled,
onClick: toggleDropdown, onClick: toggleDropdown,
onKeyDown: handleKeyDown,
onKeyUp: unsetIsKeypress,
onBlur: unsetIsKeypress,
'aria-expanded': open, 'aria-expanded': open,
'aria-controls': menuId, 'aria-controls': menuId,
ref: buttonRef, ref: buttonRef,

View File

@ -58,17 +58,7 @@ export const EditedTimestamp: React.FC<{
}, []); }, []);
const renderItem = useCallback( const renderItem = useCallback(
( (item: HistoryItem, index: number, onClick: React.MouseEventHandler) => {
item: HistoryItem,
index: number,
{
onClick,
onKeyUp,
}: {
onClick: React.MouseEventHandler;
onKeyUp: React.KeyboardEventHandler;
},
) => {
const formattedDate = ( const formattedDate = (
<RelativeTimestamp <RelativeTimestamp
timestamp={item.get('created_at') as string} timestamp={item.get('created_at') as string}
@ -98,7 +88,7 @@ export const EditedTimestamp: React.FC<{
className='dropdown-menu__item edited-timestamp__history__item' className='dropdown-menu__item edited-timestamp__history__item'
key={item.get('created_at') as string} key={item.get('created_at') as string}
> >
<button data-index={index} onClick={onClick} onKeyUp={onKeyUp}> <button data-index={index} onClick={onClick} type='button'>
{label} {label}
</button> </button>
</li> </li>
@ -118,7 +108,7 @@ export const EditedTimestamp: React.FC<{
onItemClick={handleItemClick} onItemClick={handleItemClick}
forceDropdown forceDropdown
> >
<button className='dropdown-menu__text-button'> <button className='dropdown-menu__text-button' type='button'>
<FormattedMessage <FormattedMessage
id='status.edited' id='status.edited'
defaultMessage='Edited {date}' defaultMessage='Edited {date}'

View File

@ -7,8 +7,6 @@ import {
useState, useState,
} from 'react'; } from 'react';
import classNames from 'classnames';
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize'; import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
import { autoPlayGif } from '@/mastodon/initial_state'; import { autoPlayGif } from '@/mastodon/initial_state';
import { polymorphicForwardRef } from '@/types/polymorphic'; import { polymorphicForwardRef } from '@/types/polymorphic';
@ -65,11 +63,7 @@ export const AnimateEmojiProvider = polymorphicForwardRef<
const parentContext = useContext(AnimateEmojiContext); const parentContext = useContext(AnimateEmojiContext);
if (parentContext !== null) { if (parentContext !== null) {
return ( return (
<Wrapper <Wrapper {...props} className={className} ref={ref}>
{...props}
className={classNames(className, 'animate-parent')}
ref={ref}
>
{children} {children}
</Wrapper> </Wrapper>
); );
@ -78,7 +72,7 @@ export const AnimateEmojiProvider = polymorphicForwardRef<
return ( return (
<Wrapper <Wrapper
{...props} {...props}
className={classNames(className, 'animate-parent')} className={className}
onMouseEnter={handleEnter} onMouseEnter={handleEnter}
onMouseLeave={handleLeave} onMouseLeave={handleLeave}
ref={ref} ref={ref}

View File

@ -1,9 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import classNames from 'classnames';
import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types'; import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import type { import type {
OnAttributeHandler, OnAttributeHandler,
OnElementHandler, OnElementHandler,
@ -22,7 +19,7 @@ export interface EmojiHTMLProps {
onAttribute?: OnAttributeHandler; onAttribute?: OnAttributeHandler;
} }
export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
( (
{ {
extraEmojis, extraEmojis,
@ -59,32 +56,4 @@ export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
); );
}, },
); );
ModernEmojiHTML.displayName = 'ModernEmojiHTML'; EmojiHTML.displayName = 'EmojiHTML';
export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
(props, ref) => {
const {
as: asElement,
htmlString,
extraEmojis,
className,
onElement,
onAttribute,
...rest
} = props;
const Wrapper = asElement ?? 'div';
return (
<Wrapper
{...rest}
ref={ref}
dangerouslySetInnerHTML={{ __html: htmlString }}
className={classNames(className, 'animate-parent')}
/>
);
},
);
LegacyEmojiHTML.displayName = 'LegacyEmojiHTML';
export const EmojiHTML = isModernEmojiEnabled()
? ModernEmojiHTML
: LegacyEmojiHTML;

View File

@ -27,22 +27,23 @@ export const ExitAnimationWrapper: React.FC<{
*/ */
children: (delayedIsActive: boolean) => React.ReactNode; children: (delayedIsActive: boolean) => React.ReactNode;
}> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => { }> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => {
const [delayedIsActive, setDelayedIsActive] = useState(false); const [delayedIsActive, setDelayedIsActive] = useState(
isActive && !withEntryDelay,
);
useEffect(() => { useEffect(() => {
if (isActive && !withEntryDelay) { const withDelay = !isActive || withEntryDelay;
setDelayedIsActive(true);
return () => ''; const timeout = setTimeout(
} else { () => {
const timeout = setTimeout(() => {
setDelayedIsActive(isActive); setDelayedIsActive(isActive);
}, delayMs); },
withDelay ? delayMs : 0,
);
return () => { return () => {
clearTimeout(timeout); clearTimeout(timeout);
}; };
}
}, [isActive, delayMs, withEntryDelay]); }, [isActive, delayMs, withEntryDelay]);
if (!isActive && !delayedIsActive) { if (!isActive && !delayedIsActive) {

View File

@ -1,38 +1,43 @@
import type { ComponentPropsWithRef } from 'react'; import { useCallback, useEffect, useId } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
useId,
} from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage } from 'react-intl';
import type { Map as ImmutableMap } from 'immutable'; import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import type { AnimatedProps } from '@react-spring/web';
import { animated, useSpring } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import { expandAccountFeaturedTimeline } from '@/mastodon/actions/timelines'; import { expandAccountFeaturedTimeline } from '@/mastodon/actions/timelines';
import { Icon } from '@/mastodon/components/icon'; import { Icon } from '@/mastodon/components/icon';
import { IconButton } from '@/mastodon/components/icon_button';
import { StatusQuoteManager } from '@/mastodon/components/status_quoted'; import { StatusQuoteManager } from '@/mastodon/components/status_quoted';
import { usePrevious } from '@/mastodon/hooks/usePrevious'; import {
import { useAppDispatch, useAppSelector } from '@/mastodon/store'; createAppSelector,
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; useAppDispatch,
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; useAppSelector,
} from '@/mastodon/store';
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react'; import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
import { Carousel } from './carousel';
const pinnedStatusesSelector = createAppSelector(
[
(state, accountId: string, tagged?: string) =>
(state.timelines as ImmutableMap<string, unknown>).getIn(
[`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'],
ImmutableList(),
) as ImmutableList<string>,
],
(items) => items.toArray().map((id) => ({ id })),
);
const messages = defineMessages({ const messages = defineMessages({
previous: { id: 'featured_carousel.previous', defaultMessage: 'Previous' }, previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'featured_carousel.next', defaultMessage: 'Next' }, next: { id: 'lightbox.next', defaultMessage: 'Next' },
current: {
id: 'featured_carousel.current',
defaultMessage: '<sr>Post</sr> {current, number} / {max, number}',
},
slide: { slide: {
id: 'featured_carousel.slide', id: 'featured_carousel.slide',
defaultMessage: '{index} of {total}', defaultMessage: 'Post {current, number} of {max, number}',
}, },
}); });
@ -40,7 +45,6 @@ export const FeaturedCarousel: React.FC<{
accountId: string; accountId: string;
tagged?: string; tagged?: string;
}> = ({ accountId, tagged }) => { }> = ({ accountId, tagged }) => {
const intl = useIntl();
const accessibilityId = useId(); const accessibilityId = useId();
// Load pinned statuses // Load pinned statuses
@ -50,175 +54,37 @@ export const FeaturedCarousel: React.FC<{
void dispatch(expandAccountFeaturedTimeline(accountId, { tagged })); void dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
} }
}, [accountId, dispatch, tagged]); }, [accountId, dispatch, tagged]);
const pinnedStatuses = useAppSelector( const pinnedStatuses = useAppSelector((state) =>
(state) => pinnedStatusesSelector(state, accountId, tagged),
(state.timelines as ImmutableMap<string, unknown>).getIn(
[`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'],
ImmutableList(),
) as ImmutableList<string>,
); );
// Handle slide change const renderSlide = useCallback(
const [slideIndex, setSlideIndex] = useState(0); ({ id }: { id: string }) => (
const wrapperRef = useRef<HTMLDivElement>(null); <StatusQuoteManager id={id} contextType='account' withCounters />
const handleSlideChange = useCallback( ),
(direction: number) => { [],
setSlideIndex((prev) => {
const max = pinnedStatuses.size - 1;
let newIndex = prev + direction;
if (newIndex < 0) {
newIndex = max;
} else if (newIndex > max) {
newIndex = 0;
}
const slide = wrapperRef.current?.children[newIndex];
if (slide) {
setCurrentSlideHeight(slide.scrollHeight);
}
return newIndex;
});
},
[pinnedStatuses.size],
); );
// Handle slide heights if (!accountId || pinnedStatuses.length === 0) {
const [currentSlideHeight, setCurrentSlideHeight] = useState(
wrapperRef.current?.scrollHeight ?? 0,
);
const previousSlideHeight = usePrevious(currentSlideHeight);
const observerRef = useRef<ResizeObserver>(
new ResizeObserver(() => {
handleSlideChange(0);
}),
);
const wrapperStyles = useSpring({
x: `-${slideIndex * 100}%`,
height: currentSlideHeight,
// Don't animate from zero to the height of the initial slide
immediate: !previousSlideHeight,
});
useLayoutEffect(() => {
// Update slide height when the component mounts
if (currentSlideHeight === 0) {
handleSlideChange(0);
}
}, [currentSlideHeight, handleSlideChange]);
// Handle swiping animations
const bind = useDrag(({ swipe: [swipeX] }) => {
handleSlideChange(swipeX * -1); // Invert swipe as swiping left loads the next slide.
});
const handlePrev = useCallback(() => {
handleSlideChange(-1);
}, [handleSlideChange]);
const handleNext = useCallback(() => {
handleSlideChange(1);
}, [handleSlideChange]);
if (!accountId || pinnedStatuses.isEmpty()) {
return null; return null;
} }
return ( return (
<div <Carousel
className='featured-carousel' items={pinnedStatuses}
{...bind()} renderItem={renderSlide}
aria-roledescription='carousel'
aria-labelledby={`${accessibilityId}-title`} aria-labelledby={`${accessibilityId}-title`}
role='region' classNamePrefix='featured-carousel'
messages={messages}
> >
<div className='featured-carousel__header'> <h4 className='featured-carousel__title' id={`${accessibilityId}-title`}>
<h4 <Icon id='thumb-tack' icon={PushPinIcon} />
className='featured-carousel__title' <FormattedMessage
id={`${accessibilityId}-title`} id='featured_carousel.header'
> defaultMessage='{count, plural, one {Pinned Post} other {Pinned Posts}}'
<Icon id='thumb-tack' icon={PushPinIcon} /> values={{ count: pinnedStatuses.length }}
<FormattedMessage />
id='featured_carousel.header' </h4>
defaultMessage='{count, plural, one {Pinned Post} other {Pinned Posts}}' </Carousel>
values={{ count: pinnedStatuses.size }}
/>
</h4>
{pinnedStatuses.size > 1 && (
<>
<IconButton
title={intl.formatMessage(messages.previous)}
icon='chevron-left'
iconComponent={ChevronLeftIcon}
onClick={handlePrev}
/>
<span aria-live='polite'>
<FormattedMessage
id='featured_carousel.post'
defaultMessage='Post'
>
{(text) => <span className='sr-only'>{text}</span>}
</FormattedMessage>
{slideIndex + 1} / {pinnedStatuses.size}
</span>
<IconButton
title={intl.formatMessage(messages.next)}
icon='chevron-right'
iconComponent={ChevronRightIcon}
onClick={handleNext}
/>
</>
)}
</div>
<animated.div
className='featured-carousel__slides'
ref={wrapperRef}
style={wrapperStyles}
aria-atomic='false'
aria-live='polite'
>
{pinnedStatuses.map((statusId, index) => (
<FeaturedCarouselItem
key={`f-${statusId}`}
data-index={index}
aria-label={intl.formatMessage(messages.slide, {
index: index + 1,
total: pinnedStatuses.size,
})}
statusId={statusId}
observer={observerRef.current}
active={index === slideIndex}
/>
))}
</animated.div>
</div>
);
};
interface FeaturedCarouselItemProps {
statusId: string;
active: boolean;
observer: ResizeObserver;
}
const FeaturedCarouselItem: React.FC<
FeaturedCarouselItemProps & AnimatedProps<ComponentPropsWithRef<'div'>>
> = ({ statusId, active, observer, ...props }) => {
const handleRef = useCallback(
(instance: HTMLDivElement | null) => {
if (instance) {
observer.observe(instance);
}
},
[observer],
);
return (
<animated.div
className='featured-carousel__slide'
// @ts-expect-error inert in not in this version of React
inert={!active ? 'true' : undefined}
aria-roledescription='slide'
role='group'
ref={handleRef}
{...props}
>
<StatusQuoteManager id={statusId} contextType='account' withCounters />
</animated.div>
); );
}; };

View File

@ -235,7 +235,7 @@ const HashtagBar: React.FC<{
))} ))}
{!expanded && hashtags.length > VISIBLE_HASHTAGS && ( {!expanded && hashtags.length > VISIBLE_HASHTAGS && (
<button className='link-button' onClick={handleClick}> <button className='link-button' onClick={handleClick} type='button'>
<FormattedMessage <FormattedMessage
id='hashtags.and_other' id='hashtags.and_other'
defaultMessage='…and {count, plural, other {# more}}' defaultMessage='…and {count, plural, other {# more}}'

View File

@ -153,7 +153,7 @@ export const Default = {
the app. the app.
</p> </p>
<p> <p>
When a <button>Button</button> is focused, When a <button type='button'>Button</button> is focused,
<kbd>Enter</kbd> <kbd>Enter</kbd>
should not trigger open, but <kbd>o</kbd> should not trigger open, but <kbd>o</kbd>
should. should.

View File

@ -23,8 +23,6 @@ import { domain } from 'mastodon/initial_state';
import { getAccountHidden } from 'mastodon/selectors/accounts'; import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { useLinks } from '../hooks/useLinks';
export const HoverCardAccount = forwardRef< export const HoverCardAccount = forwardRef<
HTMLDivElement, HTMLDivElement,
{ accountId?: string } { accountId?: string }
@ -66,8 +64,6 @@ export const HoverCardAccount = forwardRef<
!isMutual && !isMutual &&
!isFollower; !isFollower;
const handleClick = useLinks();
return ( return (
<div <div
ref={ref} ref={ref}
@ -110,7 +106,7 @@ export const HoverCardAccount = forwardRef<
className='hover-card__bio' className='hover-card__bio'
/> />
<div className='account-fields' onClickCapture={handleClick}> <div className='account-fields'>
<AccountFields <AccountFields
fields={account.fields.take(2)} fields={account.fields.take(2)}
emojis={account.emojis} emojis={account.emojis}

View File

@ -27,7 +27,6 @@ export const HoverCardController: React.FC = () => {
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout(); const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout(); const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
const [setScrollTimeout] = useTimeout(); const [setScrollTimeout] = useTimeout();
const location = useLocation();
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
cancelEnterTimeout(); cancelEnterTimeout();
@ -36,9 +35,12 @@ export const HoverCardController: React.FC = () => {
setAnchor(null); setAnchor(null);
}, [cancelEnterTimeout, cancelLeaveTimeout, setOpen, setAnchor]); }, [cancelEnterTimeout, cancelLeaveTimeout, setOpen, setAnchor]);
useEffect(() => { const location = useLocation();
const [previousLocation, setPreviousLocation] = useState(location);
if (location !== previousLocation) {
setPreviousLocation(location);
handleClose(); handleClose();
}, [handleClose, location]); }
useEffect(() => { useEffect(() => {
let isScrolling = false; let isScrolling = false;

View File

@ -4,7 +4,7 @@ import type { OnElementHandler } from '@/mastodon/utils/html';
import { polymorphicForwardRef } from '@/types/polymorphic'; import { polymorphicForwardRef } from '@/types/polymorphic';
import type { EmojiHTMLProps } from '../emoji/html'; import type { EmojiHTMLProps } from '../emoji/html';
import { ModernEmojiHTML } from '../emoji/html'; import { EmojiHTML } from '../emoji/html';
import { useElementHandledLink } from '../status/handled_link'; import { useElementHandledLink } from '../status/handled_link';
export const HTMLBlock = polymorphicForwardRef< export const HTMLBlock = polymorphicForwardRef<
@ -25,6 +25,6 @@ export const HTMLBlock = polymorphicForwardRef<
(...args) => onParentElement?.(...args) ?? onLinkElement(...args), (...args) => onParentElement?.(...args) ?? onLinkElement(...args),
[onLinkElement, onParentElement], [onLinkElement, onParentElement],
); );
return <ModernEmojiHTML {...props} onElement={onElement} />; return <EmojiHTML {...props} onElement={onElement} />;
}, },
); );

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, forwardRef } from 'react'; import { useCallback, forwardRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
@ -55,23 +55,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
}, },
buttonRef, buttonRef,
) => { ) => {
const [activate, setActivate] = useState(false);
const [deactivate, setDeactivate] = useState(false);
useEffect(() => {
if (!animate) {
return;
}
if (activate && !active) {
setActivate(false);
setDeactivate(true);
} else if (!activate && active) {
setActivate(true);
setDeactivate(false);
}
}, [setActivate, setDeactivate, animate, active, activate]);
const handleClick: React.MouseEventHandler<HTMLButtonElement> = useCallback( const handleClick: React.MouseEventHandler<HTMLButtonElement> = useCallback(
(e) => { (e) => {
e.preventDefault(); e.preventDefault();
@ -112,8 +95,8 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
active, active,
disabled, disabled,
inverted, inverted,
activate, activate: animate && active,
deactivate, deactivate: animate && !active,
overlayed: overlay, overlayed: overlay,
'icon-button--with-counter': typeof counter !== 'undefined', 'icon-button--with-counter': typeof counter !== 'undefined',
}); });

View File

@ -23,6 +23,7 @@ export const LearnMoreLink: React.FC<{ children: React.ReactNode }> = ({
onClick={handleClick} onClick={handleClick}
aria-expanded={open} aria-expanded={open}
aria-controls={accessibilityId} aria-controls={accessibilityId}
type='button'
> >
<FormattedMessage <FormattedMessage
id='learn_more_link.learn_more' id='learn_more_link.learn_more'
@ -48,7 +49,11 @@ export const LearnMoreLink: React.FC<{ children: React.ReactNode }> = ({
<div className='learn-more__popout__content'>{children}</div> <div className='learn-more__popout__content'>{children}</div>
<div> <div>
<button className='link-button' onClick={handleClick}> <button
className='link-button'
onClick={handleClick}
type='button'
>
<FormattedMessage <FormattedMessage
id='learn_more_link.got_it' id='learn_more_link.got_it'
defaultMessage='Got it' defaultMessage='Got it'

View File

@ -32,6 +32,7 @@ export const LoadGap = <T,>({ disabled, param, onClick }: Props<T>) => {
onClick={handleClick} onClick={handleClick}
aria-label={intl.formatMessage(messages.load_more)} aria-label={intl.formatMessage(messages.load_more)}
title={intl.formatMessage(messages.load_more)} title={intl.formatMessage(messages.load_more)}
type='button'
> >
{loading ? ( {loading ? (
<LoadingIndicator /> <LoadingIndicator />

View File

@ -7,7 +7,7 @@ interface Props {
export const LoadPending: React.FC<Props> = ({ onClick, count }) => { export const LoadPending: React.FC<Props> = ({ onClick, count }) => {
return ( return (
<button className='load-more load-gap' onClick={onClick}> <button className='load-more load-gap' onClick={onClick} type='button'>
<FormattedMessage <FormattedMessage
id='load_pending' id='load_pending'
defaultMessage='{count, plural, one {# new item} other {# new items}}' defaultMessage='{count, plural, one {# new item} other {# new items}}'

View File

@ -13,9 +13,7 @@ import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { fetchPoll, vote } from 'mastodon/actions/polls'; import { fetchPoll, vote } from 'mastodon/actions/polls';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import emojify from 'mastodon/features/emoji/emoji';
import { useIdentity } from 'mastodon/identity_context'; import { useIdentity } from 'mastodon/identity_context';
import { makeEmojiMap } from 'mastodon/models/custom_emoji';
import type * as Model from 'mastodon/models/poll'; import type * as Model from 'mastodon/models/poll';
import type { Status } from 'mastodon/models/status'; import type { Status } from 'mastodon/models/status';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
@ -37,6 +35,9 @@ const messages = defineMessages({
}, },
}); });
const isPollExpired = (expiresAt: Model.Poll['expires_at']) =>
new Date(expiresAt).getTime() < Date.now();
interface PollProps { interface PollProps {
pollId: string; pollId: string;
status: Status; status: Status;
@ -60,8 +61,7 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
if (!poll) { if (!poll) {
return false; return false;
} }
const expiresAt = poll.expires_at; return poll.expired || isPollExpired(poll.expires_at);
return poll.expired || new Date(expiresAt).getTime() < Date.now();
}, [poll]); }, [poll]);
const timeRemaining = useMemo(() => { const timeRemaining = useMemo(() => {
if (!poll) { if (!poll) {
@ -173,13 +173,14 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
className='button button-secondary' className='button button-secondary'
disabled={voteDisabled} disabled={voteDisabled}
onClick={handleVote} onClick={handleVote}
type='button'
> >
<FormattedMessage id='poll.vote' defaultMessage='Vote' /> <FormattedMessage id='poll.vote' defaultMessage='Vote' />
</button> </button>
)} )}
{!showResults && ( {!showResults && (
<> <>
<button className='poll__link' onClick={handleReveal}> <button className='poll__link' onClick={handleReveal} type='button'>
<FormattedMessage id='poll.reveal' defaultMessage='See results' /> <FormattedMessage id='poll.reveal' defaultMessage='See results' />
</button>{' '} </button>{' '}
·{' '} ·{' '}
@ -187,7 +188,11 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
)} )}
{showResults && !disabled && ( {showResults && !disabled && (
<> <>
<button className='poll__link' onClick={handleRefresh}> <button
className='poll__link'
onClick={handleRefresh}
type='button'
>
<FormattedMessage id='poll.refresh' defaultMessage='Refresh' /> <FormattedMessage id='poll.refresh' defaultMessage='Refresh' />
</button>{' '} </button>{' '}
·{' '} ·{' '}
@ -235,12 +240,11 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
let titleHtml = option.translation?.titleHtml ?? option.titleHtml; let titleHtml = option.translation?.titleHtml ?? option.titleHtml;
if (!titleHtml) { if (!titleHtml) {
const emojiMap = makeEmojiMap(poll.emojis); titleHtml = escapeTextContentForBrowser(title);
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
} }
return titleHtml; return titleHtml;
}, [option, poll, title]); }, [option, title]);
// Handlers // Handlers
const handleOptionChange = useCallback(() => { const handleOptionChange = useCallback(() => {

View File

@ -14,7 +14,7 @@ import type { Status } from '@/mastodon/models/status';
import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import type { SomeRequired } from '@/mastodon/utils/types'; import type { SomeRequired } from '@/mastodon/utils/types';
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu'; import type { RenderItemFn } from '../dropdown_menu';
import { Dropdown, DropdownMenuItemContent } from '../dropdown_menu'; import { Dropdown, DropdownMenuItemContent } from '../dropdown_menu';
import { IconButton } from '../icon_button'; import { IconButton } from '../icon_button';
@ -74,18 +74,12 @@ const StandaloneBoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
); );
}; };
const renderMenuItem: RenderItemFn<ActionMenuItem> = ( const renderMenuItem: RenderItemFn<ActionMenuItem> = (item, index, onClick) => (
item,
index,
handlers,
focusRefCallback,
) => (
<ReblogMenuItem <ReblogMenuItem
index={index} index={index}
item={item} item={item}
handlers={handlers} onClick={onClick}
key={`${item.text}-${index}`} key={`${item.text}-${index}`}
focusRefCallback={focusRefCallback}
/> />
); );
@ -118,6 +112,18 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
const statusId = status.get('id') as string; const statusId = status.get('id') as string;
const wasBoosted = !!status.get('reblogged'); const wasBoosted = !!status.get('reblogged');
let count: number | undefined;
if (counters) {
count = 0;
// Ensure count is a valid integer.
if (Number.isInteger(status.get('reblogs_count'))) {
count += status.get('reblogs_count') as number;
}
if (Number.isInteger(status.get('quotes_count'))) {
count += status.get('quotes_count') as number;
}
}
const showLoginPrompt = useCallback(() => { const showLoginPrompt = useCallback(() => {
dispatch( dispatch(
openModal({ openModal({
@ -193,12 +199,7 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
)} )}
icon='retweet' icon='retweet'
iconComponent={boostIcon} iconComponent={boostIcon}
counter={ counter={count}
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
active={isReblogged} active={isReblogged}
/> />
</Dropdown> </Dropdown>
@ -208,16 +209,10 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
interface ReblogMenuItemProps { interface ReblogMenuItemProps {
item: ActionMenuItem; item: ActionMenuItem;
index: number; index: number;
handlers: RenderItemFnHandlers; onClick: React.MouseEventHandler;
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
} }
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({ index, item, onClick }) => {
index,
item,
handlers,
focusRefCallback,
}) => {
const { text, highlighted, disabled } = item; const { text, highlighted, disabled } = item;
return ( return (
@ -228,10 +223,10 @@ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
key={`${text}-${index}`} key={`${text}-${index}`}
> >
<button <button
{...handlers} onClick={onClick}
ref={focusRefCallback}
aria-disabled={disabled} aria-disabled={disabled}
data-index={index} data-index={index}
type='button'
> >
<DropdownMenuItemContent item={item} /> <DropdownMenuItemContent item={item} />
</button> </button>

View File

@ -26,7 +26,12 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
...props ...props
}) => { }) => {
// Handle hashtags // Handle hashtags
if (text.startsWith('#') || prevText?.endsWith('#')) { if (
text.startsWith('#') ||
prevText?.endsWith('#') ||
text.startsWith('') ||
prevText?.endsWith('')
) {
const hashtag = text.slice(1).trim(); const hashtag = text.slice(1).trim();
return ( return (
<Link <Link

View File

@ -44,6 +44,7 @@ export const RemoveQuoteHint: React.FC<{
if (!firstHintId) { if (!firstHintId) {
firstHintId = uniqueId; firstHintId = uniqueId;
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsOnlyHint(true); setIsOnlyHint(true);
} }
@ -64,8 +65,8 @@ export const RemoveQuoteHint: React.FC<{
flip flip
offset={[12, 10]} offset={[12, 10]}
placement='bottom-end' placement='bottom-end'
target={anchorRef.current} target={anchorRef}
container={anchorRef.current} container={anchorRef}
> >
{({ props, placement }) => ( {({ props, placement }) => (
<div <div

View File

@ -49,6 +49,7 @@ export const StatusBanner: React.FC<{
<button <button
ref={buttonRef} ref={buttonRef}
type='button'
className='link-button' className='link-button'
onClick={onClick} onClick={onClick}
aria-describedby={descriptionId} aria-describedby={descriptionId}

View File

@ -17,8 +17,6 @@ import { Poll } from 'mastodon/components/poll';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { languages as preloadedLanguages } from 'mastodon/initial_state'; import { languages as preloadedLanguages } from 'mastodon/initial_state';
import { isModernEmojiEnabled } from '../utils/environment';
import { EmojiHTML } from './emoji/html'; import { EmojiHTML } from './emoji/html';
import { HandledLink } from './status/handled_link'; import { HandledLink } from './status/handled_link';
@ -81,7 +79,7 @@ const compareUrls = (href1, href2) => {
const url1 = new URL(href1); const url1 = new URL(href1);
const url2 = new URL(href2); const url2 = new URL(href2);
return url1.origin === url2.origin && url1.path === url2.path && url1.search === url2.search; return url1.origin === url2.origin && url1.pathname === url2.pathname && url1.search === url2.search;
} catch { } catch {
return false; return false;
} }
@ -131,41 +129,6 @@ class StatusContent extends PureComponent {
onCollapsedToggle(collapsed); onCollapsedToggle(collapsed);
} }
// Exit if modern emoji is enabled, as it handles links using the HandledLink component.
if (isModernEmojiEnabled()) {
return;
}
const links = node.querySelectorAll('a');
let link, mention;
for (var i = 0; i < links.length; ++i) {
link = links[i];
if (link.classList.contains('status-link')) {
continue;
}
link.classList.add('status-link');
mention = this.props.status.get('mentions').find(item => compareUrls(link, item.get('url')));
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
link.setAttribute('title', `@${mention.get('acct')}`);
link.setAttribute('href', `/@${mention.get('acct')}`);
link.setAttribute('data-hover-card-account', mention.get('id'));
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
link.setAttribute('data-menu-hashtag', this.props.status.getIn(['account', 'id']));
} else {
link.setAttribute('title', link.href);
link.classList.add('unhandled-link');
}
}
} }
async componentDidMount () { async componentDidMount () {
@ -204,22 +167,6 @@ class StatusContent extends PureComponent {
this._updateStatusLinks(); this._updateStatusLinks();
} }
onMentionClick = (mention, e) => {
if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.history.push(`/@${mention.get('acct')}`);
}
};
onHashtagClick = (hashtag, e) => {
hashtag = hashtag.replace(/^#/, '');
if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.history.push(`/tags/${hashtag}`);
}
};
handleMouseDown = (e) => { handleMouseDown = (e) => {
this.startXY = [e.clientX, e.clientY]; this.startXY = [e.clientX, e.clientY];
}; };
@ -299,7 +246,7 @@ class StatusContent extends PureComponent {
{children} {children}
</HandledLink> </HandledLink>
); );
} else if (element.classList.contains('quote-inline')) { } else if (element.classList.contains('quote-inline') && this.props.status.get('quote')) {
return null; return null;
} }
return undefined; return undefined;

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
@ -73,7 +73,63 @@ const LimitedAccountHint: React.FC<{ accountId: string }> = ({ accountId }) => {
defaultMessage='This account has been hidden by the moderators of {domain}.' defaultMessage='This account has been hidden by the moderators of {domain}.'
values={{ domain }} values={{ domain }}
/> />
<button onClick={reveal} className='link-button'> <button onClick={reveal} className='link-button' type='button'>
<FormattedMessage
id='status.quote_error.limited_account_hint.action'
defaultMessage='Show anyway'
/>
</button>
</>
);
};
const FilteredQuote: React.FC<{
reveal: VoidFunction;
quotedAccountId: string;
quoteState: string;
}> = ({ reveal, quotedAccountId, quoteState }) => {
const account = useAppSelector((state) =>
quotedAccountId ? state.accounts.get(quotedAccountId) : undefined,
);
const quoteAuthorName = account?.acct;
const domain = quoteAuthorName?.split('@')[1];
let message;
switch (quoteState) {
case 'blocked_account':
message = (
<FormattedMessage
id='status.quote_error.blocked_account_hint.title'
defaultMessage="This post is hidden because you've blocked @{name}."
values={{ name: quoteAuthorName }}
/>
);
break;
case 'blocked_domain':
message = (
<FormattedMessage
id='status.quote_error.blocked_domain_hint.title'
defaultMessage="This post is hidden because you've blocked {domain}."
values={{ domain }}
/>
);
break;
case 'muted_account':
message = (
<FormattedMessage
id='status.quote_error.muted_account_hint.title'
defaultMessage="This post is hidden because you've muted @{name}."
values={{ name: quoteAuthorName }}
/>
);
}
return (
<>
{message}
<button onClick={reveal} className='link-button' type='button'>
<FormattedMessage <FormattedMessage
id='status.quote_error.limited_account_hint.action' id='status.quote_error.limited_account_hint.action'
defaultMessage='Show anyway' defaultMessage='Show anyway'
@ -130,6 +186,11 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
const isLoaded = loadingState === 'complete'; const isLoaded = loadingState === 'complete';
const isFetchingQuoteRef = useRef(false); const isFetchingQuoteRef = useRef(false);
const [revealed, setRevealed] = useState(false);
const reveal = useCallback(() => {
setRevealed(true);
}, [setRevealed]);
useEffect(() => { useEffect(() => {
if (isLoaded) { if (isLoaded) {
@ -189,6 +250,20 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
defaultMessage='Post removed by author' defaultMessage='Post removed by author'
/> />
); );
} else if (
(quoteState === 'blocked_account' ||
quoteState === 'blocked_domain' ||
quoteState === 'muted_account') &&
!revealed &&
accountId
) {
quoteError = (
<FilteredQuote
quoteState={quoteState}
reveal={reveal}
quotedAccountId={accountId}
/>
);
} else if ( } else if (
!status || !status ||
!quotedStatusId || !quotedStatusId ||

View File

@ -1,30 +1,10 @@
import { EmojiHTML } from '@/mastodon/components/emoji/html'; import { EmojiHTML } from '@/mastodon/components/emoji/html';
import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { isModernEmojiEnabled } from '../utils/environment';
import type { OnAttributeHandler } from '../utils/html'; import type { OnAttributeHandler } from '../utils/html';
import { Icon } from './icon'; import { Icon } from './icon';
const domParser = new DOMParser();
const stripRelMe = (html: string) => {
if (isModernEmojiEnabled()) {
return html;
}
const document = domParser.parseFromString(html, 'text/html').documentElement;
document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => {
link.rel = link.rel
.split(' ')
.filter((x: string) => x !== 'me')
.join(' ');
});
const body = document.querySelector('body');
return body?.innerHTML ?? '';
};
const onAttribute: OnAttributeHandler = (name, value, tagName) => { const onAttribute: OnAttributeHandler = (name, value, tagName) => {
if (name === 'rel' && tagName === 'a') { if (name === 'rel' && tagName === 'a') {
if (value === 'me') { if (value === 'me') {
@ -47,10 +27,6 @@ interface Props {
export const VerifiedBadge: React.FC<Props> = ({ link }) => ( export const VerifiedBadge: React.FC<Props> = ({ link }) => (
<span className='verified-badge'> <span className='verified-badge'>
<Icon id='check' icon={CheckIcon} className='verified-badge__mark' /> <Icon id='check' icon={CheckIcon} className='verified-badge__mark' />
<EmojiHTML <EmojiHTML as='span' htmlString={link} onAttribute={onAttribute} />
as='span'
htmlString={stripRelMe(link)}
onAttribute={onAttribute}
/>
</span> </span>
); );

View File

@ -10,7 +10,7 @@ import ModalRoot from 'mastodon/components/modal_root';
import { Poll } from 'mastodon/components/poll'; import { Poll } from 'mastodon/components/poll';
import { Audio } from 'mastodon/features/audio'; import { Audio } from 'mastodon/features/audio';
import Card from 'mastodon/features/status/components/card'; import Card from 'mastodon/features/status/components/card';
import MediaModal from 'mastodon/features/ui/components/media_modal'; import { MediaModal } from 'mastodon/features/ui/components/media_modal';
import { Video } from 'mastodon/features/video'; import { Video } from 'mastodon/features/video';
import { IntlProvider } from 'mastodon/locales'; import { IntlProvider } from 'mastodon/locales';
import { createPollFromServerJSON } from 'mastodon/models/poll'; import { createPollFromServerJSON } from 'mastodon/models/poll';

View File

@ -86,6 +86,7 @@ export const ScrollContext: React.FC<ScrollContextProps> = ({
) => ) =>
// Hack to allow accessing scrollBehavior._stateStorage // Hack to allow accessing scrollBehavior._stateStorage
shouldUpdateScroll.call( shouldUpdateScroll.call(
// eslint-disable-next-line react-hooks/immutability
scrollBehavior, scrollBehavior,
prevLocationContext, prevLocationContext,
locationContext, locationContext,

View File

@ -32,16 +32,38 @@ interface Rule extends BaseRule {
translations?: Record<string, BaseRule>; translations?: Record<string, BaseRule>;
} }
function getDefaultSelectedLocale(
currentUiLocale: string,
localeOptions: SelectItem[],
) {
const preciseMatch = localeOptions.find(
(option) => option.value === currentUiLocale,
);
if (preciseMatch) {
return preciseMatch.value;
}
const partialLocale = currentUiLocale.split('-')[0];
const partialMatch = localeOptions.find(
(option) => option.value.split('-')[0] === partialLocale,
);
return partialMatch?.value ?? 'default';
}
export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => { export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
const intl = useIntl(); const intl = useIntl();
const [locale, setLocale] = useState(intl.locale);
const rules = useAppSelector((state) => rulesSelector(state, locale));
const localeOptions = useAppSelector((state) => const localeOptions = useAppSelector((state) =>
localeOptionsSelector(state, intl), localeOptionsSelector(state, intl),
); );
const [selectedLocale, setSelectedLocale] = useState(() =>
getDefaultSelectedLocale(intl.locale, localeOptions),
);
const rules = useAppSelector((state) => rulesSelector(state, selectedLocale));
const handleLocaleChange: ChangeEventHandler<HTMLSelectElement> = useCallback( const handleLocaleChange: ChangeEventHandler<HTMLSelectElement> = useCallback(
(e) => { (e) => {
setLocale(e.currentTarget.value); setSelectedLocale(e.currentTarget.value);
}, },
[], [],
); );
@ -74,25 +96,27 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
))} ))}
</ol> </ol>
<div className='rules-languages'> {localeOptions.length > 1 && (
<label htmlFor='language-select'> <div className='rules-languages'>
<FormattedMessage <label htmlFor='language-select'>
id='about.language_label' <FormattedMessage
defaultMessage='Language' id='about.language_label'
/> defaultMessage='Language'
</label> />
<select onChange={handleLocaleChange} id='language-select'> </label>
{localeOptions.map((option) => ( <select onChange={handleLocaleChange} id='language-select'>
<option {localeOptions.map((option) => (
key={option.value} <option
value={option.value} key={option.value}
selected={option.value === locale} value={option.value}
> selected={option.value === selectedLocale}
{option.text} >
</option> {option.text}
))} </option>
</select> ))}
</div> </select>
</div>
)}
</Section> </Section>
); );
}; };

View File

@ -31,6 +31,7 @@ export const Section: FC<SectionProps> = ({
className='about__section__title' className='about__section__title'
tabIndex={0} tabIndex={0}
onClick={handleClick} onClick={handleClick}
type='button'
> >
<Icon <Icon
id={collapsed ? 'chevron-right' : 'chevron-down'} id={collapsed ? 'chevron-right' : 'chevron-down'}

View File

@ -37,6 +37,7 @@ export const DomainPill: React.FC<{
onClick={handleClick} onClick={handleClick}
aria-expanded={open} aria-expanded={open}
aria-controls={accessibilityId} aria-controls={accessibilityId}
type='button'
> >
{domain} {domain}
</button> </button>
@ -154,6 +155,7 @@ export const DomainPill: React.FC<{
<button <button
onClick={handleExpandClick} onClick={handleExpandClick}
className='link-button' className='link-button'
type='button'
> >
{x} {x}
</button> </button>
@ -169,6 +171,7 @@ export const DomainPill: React.FC<{
<button <button
onClick={handleExpandClick} onClick={handleExpandClick}
className='link-button' className='link-button'
type='button'
> >
{x} {x}
</button> </button>

View File

@ -49,7 +49,6 @@ import { ShortNumber } from 'mastodon/components/short_number';
import { AccountNote } from 'mastodon/features/account/components/account_note'; import { AccountNote } from 'mastodon/features/account/components/account_note';
import { DomainPill } from 'mastodon/features/account/components/domain_pill'; import { DomainPill } from 'mastodon/features/account/components/domain_pill';
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container'; import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
import { useLinks } from 'mastodon/hooks/useLinks';
import { useIdentity } from 'mastodon/identity_context'; import { useIdentity } from 'mastodon/identity_context';
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state'; import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account'; import type { Account } from 'mastodon/models/account';
@ -198,7 +197,6 @@ export const AccountHeader: React.FC<{
state.relationships.get(accountId), state.relationships.get(accountId),
); );
const hidden = useAppSelector((state) => getAccountHidden(state, accountId)); const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
const handleLinkClick = useLinks();
const handleBlock = useCallback(() => { const handleBlock = useCallback(() => {
if (!account) { if (!account) {
@ -852,10 +850,7 @@ export const AccountHeader: React.FC<{
{!(suspended || hidden) && ( {!(suspended || hidden) && (
<div className='account__header__extra'> <div className='account__header__extra'>
<div <div className='account__header__bio'>
className='account__header__bio'
onClickCapture={handleLinkClick}
>
{account.id !== me && signedIn && ( {account.id !== me && signedIn && (
<AccountNote accountId={accountId} /> <AccountNote accountId={accountId} />
)} )}

View File

@ -101,16 +101,17 @@ const Preview: React.FC<{
position: FocalPoint; position: FocalPoint;
onPositionChange: (arg0: FocalPoint) => void; onPositionChange: (arg0: FocalPoint) => void;
}> = ({ mediaId, position, onPositionChange }) => { }> = ({ mediaId, position, onPositionChange }) => {
const draggingRef = useRef<boolean>(false);
const nodeRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null); const nodeRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null);
const [dragging, setDragging] = useState<'started' | 'moving' | null>(null);
const [x, y] = position; const [x, y] = position;
const style = useSpring({ const style = useSpring({
to: { to: {
left: `${x * 100}%`, left: `${x * 100}%`,
top: `${y * 100}%`, top: `${y * 100}%`,
}, },
immediate: draggingRef.current, immediate: dragging === 'moving',
}); });
const media = useAppSelector((state) => const media = useAppSelector((state) =>
( (
@ -123,8 +124,6 @@ const Preview: React.FC<{
me ? state.accounts.get(me) : undefined, me ? state.accounts.get(me) : undefined,
); );
const [dragging, setDragging] = useState(false);
const setRef = useCallback( const setRef = useCallback(
(e: HTMLImageElement | HTMLVideoElement | null) => { (e: HTMLImageElement | HTMLVideoElement | null) => {
nodeRef.current = e; nodeRef.current = e;
@ -140,20 +139,20 @@ const Preview: React.FC<{
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
const { x, y } = getPointerPosition(nodeRef.current, e); const { x, y } = getPointerPosition(nodeRef.current, e);
draggingRef.current = true; // This will disable the animation for quicker feedback, only do this if the mouse actually moves
setDragging('moving'); // This will disable the animation for quicker feedback, only do this if the mouse actually moves
onPositionChange([x, y]); onPositionChange([x, y]);
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
setDragging(false); setDragging(null);
draggingRef.current = false;
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
}; };
const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent); const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent);
setDragging(true); setDragging('started');
onPositionChange([x, y]); onPositionChange([x, y]);
document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
@ -489,6 +488,7 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
className='link-button' className='link-button'
onClick={handleDetectClick} onClick={handleDetectClick}
disabled={type !== 'image' || isDetecting} disabled={type !== 'image' || isDetecting}
type='button'
> >
<FormattedMessage <FormattedMessage
id='alt_text_modal.add_text_from_image' id='alt_text_modal.add_text_from_image'

View File

@ -31,15 +31,13 @@ export const AnnualReport: React.FC<{
year: string; year: string;
}> = ({ year }) => { }> = ({ year }) => {
const [response, setResponse] = useState<AnnualReportResponse | null>(null); const [response, setResponse] = useState<AnnualReportResponse | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
const currentAccount = useAppSelector((state) => const currentAccount = useAppSelector((state) =>
me ? state.accounts.get(me) : undefined, me ? state.accounts.get(me) : undefined,
); );
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {
setLoading(true);
apiRequestGet<AnnualReportResponse>(`v1/annual_reports/${year}`) apiRequestGet<AnnualReportResponse>(`v1/annual_reports/${year}`)
.then((data) => { .then((data) => {
dispatch(importFetchedStatuses(data.statuses)); dispatch(importFetchedStatuses(data.statuses));

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback, useState, useId } from 'react'; import { useEffect, useRef, useCallback, useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
@ -22,6 +22,8 @@ import { useAudioVisualizer } from 'mastodon/hooks/useAudioVisualizer';
import { displayMedia, useBlurhash } from 'mastodon/initial_state'; import { displayMedia, useBlurhash } from 'mastodon/initial_state';
import { playerSettings } from 'mastodon/settings'; import { playerSettings } from 'mastodon/settings';
import { AudioVisualizer } from './visualizer';
const messages = defineMessages({ const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' }, play: { id: 'video.play', defaultMessage: 'Play' },
pause: { id: 'video.pause', defaultMessage: 'Pause' }, pause: { id: 'video.pause', defaultMessage: 'Pause' },
@ -116,7 +118,6 @@ export const Audio: React.FC<{
const seekRef = useRef<HTMLDivElement>(null); const seekRef = useRef<HTMLDivElement>(null);
const volumeRef = useRef<HTMLDivElement>(null); const volumeRef = useRef<HTMLDivElement>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(); const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
const accessibilityId = useId();
const { audioContextRef, sourceRef, gainNodeRef, playAudio, pauseAudio } = const { audioContextRef, sourceRef, gainNodeRef, playAudio, pauseAudio } =
useAudioContext({ audioElementRef: audioRef }); useAudioContext({ audioElementRef: audioRef });
@ -538,19 +539,6 @@ export const Audio: React.FC<{
[togglePlay, toggleMute], [togglePlay, toggleMute],
); );
const springForBand0 = useSpring({
to: { r: 50 + (frequencyBands[0] ?? 0) * 10 },
config: config.wobbly,
});
const springForBand1 = useSpring({
to: { r: 50 + (frequencyBands[1] ?? 0) * 10 },
config: config.wobbly,
});
const springForBand2 = useSpring({
to: { r: 50 + (frequencyBands[2] ?? 0) * 10 },
config: config.wobbly,
});
const progress = Math.min((currentTime / loadedDuration) * 100, 100); const progress = Math.min((currentTime / loadedDuration) * 100, 100);
const effectivelyMuted = muted || volume === 0; const effectivelyMuted = muted || volume === 0;
@ -641,81 +629,7 @@ export const Audio: React.FC<{
</div> </div>
<div className='audio-player__controls__play'> <div className='audio-player__controls__play'>
<svg <AudioVisualizer frequencyBands={frequencyBands} poster={poster} />
className='audio-player__visualizer'
viewBox='0 0 124 124'
xmlns='http://www.w3.org/2000/svg'
>
<animated.circle
opacity={0.5}
cx={57}
cy={62.5}
r={springForBand0.r}
fill='var(--player-accent-color)'
/>
<animated.circle
opacity={0.5}
cx={65}
cy={57.5}
r={springForBand1.r}
fill='var(--player-accent-color)'
/>
<animated.circle
opacity={0.5}
cx={63}
cy={66.5}
r={springForBand2.r}
fill='var(--player-accent-color)'
/>
<g clipPath={`url(#${accessibilityId}-clip)`}>
<rect
x={14}
y={14}
width={96}
height={96}
fill={`url(#${accessibilityId}-pattern)`}
/>
<rect
x={14}
y={14}
width={96}
height={96}
fill='var(--player-background-color'
opacity={0.45}
/>
</g>
<defs>
<pattern
id={`${accessibilityId}-pattern`}
patternContentUnits='objectBoundingBox'
width='1'
height='1'
>
<use href={`#${accessibilityId}-image`} />
</pattern>
<clipPath id={`${accessibilityId}-clip`}>
<rect
x={14}
y={14}
width={96}
height={96}
rx={48}
fill='white'
/>
</clipPath>
<image
id={`${accessibilityId}-image`}
href={poster}
width={1}
height={1}
preserveAspectRatio='none'
/>
</defs>
</svg>
<button <button
type='button' type='button'

View File

@ -0,0 +1,100 @@
import { useId } from 'react';
import type { FC } from 'react';
import { animated, config, useSpring } from '@react-spring/web';
interface AudioVisualizerProps {
frequencyBands?: number[];
poster?: string;
}
export const AudioVisualizer: FC<AudioVisualizerProps> = ({
frequencyBands = [],
poster,
}) => {
const accessibilityId = useId();
const springForBand0 = useSpring({
to: { r: 50 + (frequencyBands[0] ?? 0) * 10 },
config: config.wobbly,
});
const springForBand1 = useSpring({
to: { r: 50 + (frequencyBands[1] ?? 0) * 10 },
config: config.wobbly,
});
const springForBand2 = useSpring({
to: { r: 50 + (frequencyBands[2] ?? 0) * 10 },
config: config.wobbly,
});
return (
<svg
className='audio-player__visualizer'
viewBox='0 0 124 124'
xmlns='http://www.w3.org/2000/svg'
>
<animated.circle
opacity={0.5}
cx={57}
cy={62.5}
r={springForBand0.r}
fill='var(--player-accent-color)'
/>
<animated.circle
opacity={0.5}
cx={65}
cy={57.5}
r={springForBand1.r}
fill='var(--player-accent-color)'
/>
<animated.circle
opacity={0.5}
cx={63}
cy={66.5}
r={springForBand2.r}
fill='var(--player-accent-color)'
/>
<g clipPath={`url(#${accessibilityId}-clip)`}>
<rect
x={14}
y={14}
width={96}
height={96}
fill={`url(#${accessibilityId}-pattern)`}
/>
<rect
x={14}
y={14}
width={96}
height={96}
fill='var(--player-background-color'
opacity={0.45}
/>
</g>
<defs>
<pattern
id={`${accessibilityId}-pattern`}
patternContentUnits='objectBoundingBox'
width='1'
height='1'
>
<use href={`#${accessibilityId}-image`} />
</pattern>
<clipPath id={`${accessibilityId}-clip`}>
<rect x={14} y={14} width={96} height={96} rx={48} fill='white' />
</clipPath>
<image
id={`${accessibilityId}-image`}
href={poster}
width={1}
height={1}
preserveAspectRatio='none'
/>
</defs>
</svg>
);
};

View File

@ -123,11 +123,10 @@ class ComposeForm extends ImmutablePureComponent {
}; };
canSubmit = () => { canSubmit = () => {
const { isSubmitting, isChangingUpload, isUploading, anyMedia, maxChars } = this.props; const { isSubmitting, isChangingUpload, isUploading, maxChars } = this.props;
const fulltext = this.getFulltextForCharacterCounting(); const fulltext = this.getFulltextForCharacterCounting();
const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (isOnlyWhitespace && !anyMedia)); return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars);
}; };
handleSubmit = (e) => { handleSubmit = (e) => {
@ -141,7 +140,10 @@ class ComposeForm extends ImmutablePureComponent {
return; return;
} }
this.props.onSubmit(missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct'); this.props.onSubmit({
missingAltText: missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct',
quoteToPrivate: this.props.quoteToPrivate,
});
if (e) { if (e) {
e.preventDefault(); e.preventDefault();

View File

@ -55,6 +55,8 @@ const getFrequentlyUsedLanguages = createSelector(
.toArray(), .toArray(),
); );
const isTextLongEnoughForGuess = (text: string) => text.length > 20;
const LanguageDropdownMenu: React.FC<{ const LanguageDropdownMenu: React.FC<{
value: string; value: string;
guess?: string; guess?: string;
@ -375,14 +377,27 @@ export const LanguageDropdown: React.FC = () => {
); );
useEffect(() => { useEffect(() => {
if (text.length > 20) { if (isTextLongEnoughForGuess(text)) {
debouncedGuess(text, setGuess); debouncedGuess(text, setGuess);
} else { } else {
debouncedGuess.cancel(); debouncedGuess.cancel();
setGuess('');
} }
}, [text, setGuess]); }, [text, setGuess]);
// Keeping track of the previous render's text length here
// to be able to reset the guess when the text length drops
// below the threshold needed to make a guess
const [wasLongText, setWasLongText] = useState(() =>
isTextLongEnoughForGuess(text),
);
if (wasLongText !== isTextLongEnoughForGuess(text)) {
setWasLongText(isTextLongEnoughForGuess(text));
if (wasLongText) {
setGuess('');
}
}
return ( return (
<div ref={targetRef}> <div ref={targetRef}>
<button <button

View File

@ -0,0 +1,48 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { cancelPasteLinkCompose } from '@/mastodon/actions/compose_typed';
import { useAppDispatch } from '@/mastodon/store';
import CancelFillIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import { DisplayName } from 'mastodon/components/display_name';
import { IconButton } from 'mastodon/components/icon_button';
import { Skeleton } from 'mastodon/components/skeleton';
const messages = defineMessages({
quote_cancel: { id: 'status.quote.cancel', defaultMessage: 'Cancel quote' },
});
export const QuotePlaceholder: FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleQuoteCancel = useCallback(() => {
dispatch(cancelPasteLinkCompose());
}, [dispatch]);
return (
<div className='status__quote'>
<div className='status'>
<div className='status__info'>
<div className='status__avatar'>
<Skeleton width='32px' height='32px' />
</div>
<div className='status__display-name'>
<DisplayName />
</div>
<IconButton
onClick={handleQuoteCancel}
className='status__quote-cancel'
title={intl.formatMessage(messages.quote_cancel)}
icon='cancel-fill'
iconComponent={CancelFillIcon}
/>
</div>
<div className='status__content'>
<Skeleton />
</div>
</div>
</div>
);
};

View File

@ -7,11 +7,17 @@ import { quoteComposeCancel } from '@/mastodon/actions/compose_typed';
import { QuotedStatus } from '@/mastodon/components/status_quoted'; import { QuotedStatus } from '@/mastodon/components/status_quoted';
import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { QuotePlaceholder } from './quote_placeholder';
export const ComposeQuotedStatus: FC = () => { export const ComposeQuotedStatus: FC = () => {
const quotedStatusId = useAppSelector( const quotedStatusId = useAppSelector(
(state) => state.compose.get('quoted_status_id') as string | null, (state) => state.compose.get('quoted_status_id') as string | null,
); );
const isFetchingLink = useAppSelector(
(state) => !!state.compose.get('fetching_link'),
);
const isEditing = useAppSelector((state) => !!state.compose.get('id')); const isEditing = useAppSelector((state) => !!state.compose.get('id'));
const quote = useMemo( const quote = useMemo(
@ -30,7 +36,9 @@ export const ComposeQuotedStatus: FC = () => {
dispatch(quoteComposeCancel()); dispatch(quoteComposeCancel());
}, [dispatch]); }, [dispatch]);
if (!quote) { if (isFetchingLink && !quote) {
return <QuotePlaceholder />;
} else if (!quote) {
return null; return null;
} }

View File

@ -1,4 +1,4 @@
import { useCallback, useState, useRef, useEffect } from 'react'; import { useCallback, useState, useRef, useEffect, useMemo } from 'react';
import { import {
defineMessages, defineMessages,
@ -97,173 +97,13 @@ export const Search: React.FC<{
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [selectedOption, setSelectedOption] = useState(-1); const [selectedOption, setSelectedOption] = useState(-1);
const [quickActions, setQuickActions] = useState<SearchOption[]>([]); const [quickActions, setQuickActions] = useState<SearchOption[]>([]);
useEffect(() => {
setValue(initialValue ?? '');
setQuickActions([]);
}, [initialValue]);
const searchOptions: SearchOption[] = [];
const unfocus = useCallback(() => { const unfocus = useCallback(() => {
document.querySelector('.ui')?.parentElement?.focus(); document.querySelector('.ui')?.parentElement?.focus();
setExpanded(false); setExpanded(false);
}, []); }, []);
if (searchEnabled) { const insertText = useCallback((text: string) => {
searchOptions.push(
{
key: 'prompt-has',
label: (
<>
<mark>has:</mark>{' '}
<FormattedList
type='disjunction'
value={['media', 'poll', 'embed']}
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('has:');
},
},
{
key: 'prompt-is',
label: (
<>
<mark>is:</mark>{' '}
<FormattedList type='disjunction' value={['reply', 'sensitive']} />
</>
),
action: (e) => {
e.preventDefault();
insertText('is:');
},
},
{
key: 'prompt-language',
label: (
<>
<mark>language:</mark>{' '}
<FormattedMessage
id='search_popout.language_code'
defaultMessage='ISO language code'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('language:');
},
},
{
key: 'prompt-from',
label: (
<>
<mark>from:</mark>{' '}
<FormattedMessage id='search_popout.user' defaultMessage='user' />
</>
),
action: (e) => {
e.preventDefault();
insertText('from:');
},
},
{
key: 'prompt-before',
label: (
<>
<mark>before:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('before:');
},
},
{
key: 'prompt-during',
label: (
<>
<mark>during:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('during:');
},
},
{
key: 'prompt-after',
label: (
<>
<mark>after:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('after:');
},
},
{
key: 'prompt-in',
label: (
<>
<mark>in:</mark>{' '}
<FormattedList
type='disjunction'
value={['all', 'library', 'public']}
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('in:');
},
},
);
}
const recentOptions: SearchOption[] = recent.map((search) => ({
key: `${search.type}/${search.q}`,
label: labelForRecentSearch(search),
action: () => {
setValue(search.q);
if (search.type === 'account') {
history.push(`/@${search.q}`);
} else if (search.type === 'hashtag') {
history.push(`/tags/${search.q}`);
} else {
const queryParams = new URLSearchParams({ q: search.q });
if (search.type) queryParams.set('type', search.type);
history.push({ pathname: '/search', search: queryParams.toString() });
}
unfocus();
},
forget: (e) => {
e.stopPropagation();
void dispatch(forgetSearchResult(search));
},
}));
const navigableOptions = hasValue
? quickActions.concat(searchOptions)
: recentOptions.concat(quickActions, searchOptions);
const insertText = (text: string) => {
setValue((currentValue) => { setValue((currentValue) => {
if (currentValue === '') { if (currentValue === '') {
return text; return text;
@ -273,7 +113,181 @@ export const Search: React.FC<{
return `${currentValue} ${text}`; return `${currentValue} ${text}`;
} }
}); });
}; }, []);
const searchOptions = useMemo(() => {
if (!searchEnabled) {
return [];
} else {
const options: SearchOption[] = [
{
key: 'prompt-has',
label: (
<>
<mark>has:</mark>{' '}
<FormattedList
type='disjunction'
value={['media', 'poll', 'embed']}
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('has:');
},
},
{
key: 'prompt-is',
label: (
<>
<mark>is:</mark>{' '}
<FormattedList
type='disjunction'
value={['reply', 'sensitive']}
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('is:');
},
},
{
key: 'prompt-language',
label: (
<>
<mark>language:</mark>{' '}
<FormattedMessage
id='search_popout.language_code'
defaultMessage='ISO language code'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('language:');
},
},
{
key: 'prompt-from',
label: (
<>
<mark>from:</mark>{' '}
<FormattedMessage id='search_popout.user' defaultMessage='user' />
</>
),
action: (e) => {
e.preventDefault();
insertText('from:');
},
},
{
key: 'prompt-before',
label: (
<>
<mark>before:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('before:');
},
},
{
key: 'prompt-during',
label: (
<>
<mark>during:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('during:');
},
},
{
key: 'prompt-after',
label: (
<>
<mark>after:</mark>{' '}
<FormattedMessage
id='search_popout.specific_date'
defaultMessage='specific date'
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('after:');
},
},
{
key: 'prompt-in',
label: (
<>
<mark>in:</mark>{' '}
<FormattedList
type='disjunction'
value={['all', 'library', 'public']}
/>
</>
),
action: (e) => {
e.preventDefault();
insertText('in:');
},
},
];
return options;
}
}, [insertText]);
const recentOptions: SearchOption[] = useMemo(
() =>
recent.map((search) => ({
key: `${search.type}/${search.q}`,
label: labelForRecentSearch(search),
action: () => {
setValue(search.q);
if (search.type === 'account') {
history.push(`/@${search.q}`);
} else if (search.type === 'hashtag') {
history.push(`/tags/${search.q}`);
} else {
const queryParams = new URLSearchParams({ q: search.q });
if (search.type) queryParams.set('type', search.type);
history.push({
pathname: '/search',
search: queryParams.toString(),
});
}
unfocus();
},
forget: (e) => {
e.stopPropagation();
void dispatch(forgetSearchResult(search));
},
})),
[dispatch, history, recent, unfocus],
);
const navigableOptions: SearchOption[] = useMemo(
() =>
hasValue
? quickActions.concat(searchOptions)
: recentOptions.concat(quickActions, searchOptions),
[hasValue, quickActions, recentOptions, searchOptions],
);
const submit = useCallback( const submit = useCallback(
(q: string, type?: SearchType) => { (q: string, type?: SearchType) => {
@ -557,7 +571,11 @@ export const Search: React.FC<{
)} )}
> >
<span>{label}</span> <span>{label}</span>
<button className='icon-button' onMouseDown={forget}> <button
className='icon-button'
onMouseDown={forget}
type='button'
>
<Icon id='times' icon={CloseIcon} /> <Icon id='times' icon={CloseIcon} />
</button> </button>
</div> </div>
@ -591,6 +609,7 @@ export const Search: React.FC<{
className={classNames('search__popout__menu__item', { className={classNames('search__popout__menu__item', {
selected: selectedOption === i, selected: selectedOption === i,
})} })}
type='button'
> >
{label} {label}
</button> </button>
@ -617,6 +636,7 @@ export const Search: React.FC<{
selectedOption === selectedOption ===
(quickActions.length || recent.length) + i, (quickActions.length || recent.length) + i,
})} })}
type='button'
> >
{label} {label}
</button> </button>

View File

@ -10,6 +10,7 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import CloseIcon from '@/material-icons/400-20px/close.svg?react'; import CloseIcon from '@/material-icons/400-20px/close.svg?react';
import SoundIcon from '@/material-icons/400-24px/audio.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react'; import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { undoUploadCompose } from 'mastodon/actions/compose'; import { undoUploadCompose } from 'mastodon/actions/compose';
@ -17,7 +18,18 @@ import { openModal } from 'mastodon/actions/modal';
import { Blurhash } from 'mastodon/components/blurhash'; import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import type { MediaAttachment } from 'mastodon/models/media_attachment'; import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from 'mastodon/store';
import { AudioVisualizer } from '../../audio/visualizer';
const selectUserAvatar = createAppSelector(
[(state) => state.accounts, (state) => state.meta.get('me') as string],
(accounts, myId) => accounts.get(myId)?.avatar_static,
);
export const Upload: React.FC<{ export const Upload: React.FC<{
id: string; id: string;
@ -38,6 +50,7 @@ export const Upload: React.FC<{
const sensitive = useAppSelector( const sensitive = useAppSelector(
(state) => state.compose.get('spoiler') as boolean, (state) => state.compose.get('spoiler') as boolean,
); );
const userAvatar = useAppSelector(selectUserAvatar);
const handleUndoClick = useCallback(() => { const handleUndoClick = useCallback(() => {
dispatch(undoUploadCompose(id)); dispatch(undoUploadCompose(id));
@ -67,6 +80,8 @@ export const Upload: React.FC<{
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
}; };
const preview_url = media.get('preview_url') as string | null;
const blurhash = media.get('blurhash') as string | null;
return ( return (
<div <div
@ -85,17 +100,19 @@ export const Upload: React.FC<{
<div <div
className='compose-form__upload__thumbnail' className='compose-form__upload__thumbnail'
style={{ style={{
backgroundImage: !sensitive backgroundImage:
? `url(${media.get('preview_url') as string})` !sensitive && preview_url ? `url(${preview_url})` : undefined,
: undefined,
backgroundPosition: `${x}% ${y}%`, backgroundPosition: `${x}% ${y}%`,
}} }}
> >
{sensitive && ( {sensitive && blurhash && (
<Blurhash <Blurhash hash={blurhash} className='compose-form__upload__preview' />
hash={media.get('blurhash') as string} )}
className='compose-form__upload__preview' {!sensitive && !preview_url && (
/> <div className='compose-form__upload__visualizer'>
<AudioVisualizer poster={userAvatar} />
<Icon id='sound' icon={SoundIcon} />
</div>
)} )}
<div className='compose-form__upload__actions'> <div className='compose-form__upload__actions'>

View File

@ -5,8 +5,10 @@ import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { changeComposeVisibility } from '@/mastodon/actions/compose'; import {
import { setComposeQuotePolicy } from '@/mastodon/actions/compose_typed'; changeComposeVisibility,
setComposeQuotePolicy,
} from '@/mastodon/actions/compose_typed';
import { openModal } from '@/mastodon/actions/modal'; import { openModal } from '@/mastodon/actions/modal';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes'; import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import type { StatusVisibility } from '@/mastodon/api_types/statuses'; import type { StatusVisibility } from '@/mastodon/api_types/statuses';

View File

@ -12,6 +12,7 @@ import {
} from 'mastodon/actions/compose'; } from 'mastodon/actions/compose';
import { pasteLinkCompose } from 'mastodon/actions/compose_typed'; import { pasteLinkCompose } from 'mastodon/actions/compose_typed';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { PRIVATE_QUOTE_MODAL_ID } from 'mastodon/features/ui/components/confirmation_modals/private_quote_notify';
import ComposeForm from '../components/compose_form'; import ComposeForm from '../components/compose_form';
@ -32,6 +33,10 @@ const mapStateToProps = state => ({
isUploading: state.getIn(['compose', 'is_uploading']), isUploading: state.getIn(['compose', 'is_uploading']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, 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), missingAltText: state.getIn(['compose', 'media_attachments']).some(media => ['image', 'gifv'].includes(media.get('type')) && (media.get('description') ?? '').length === 0),
quoteToPrivate:
!!state.getIn(['compose', 'quoted_status_id'])
&& state.getIn(['compose', 'privacy']) === 'private'
&& !state.getIn(['settings', 'dismissed_banners', PRIVATE_QUOTE_MODAL_ID]),
isInReply: state.getIn(['compose', 'in_reply_to']) !== null, isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
lang: state.getIn(['compose', 'language']), lang: state.getIn(['compose', 'language']),
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500), maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
@ -43,12 +48,17 @@ const mapDispatchToProps = (dispatch, props) => ({
dispatch(changeCompose(text)); dispatch(changeCompose(text));
}, },
onSubmit (missingAltText) { onSubmit ({ missingAltText, quoteToPrivate }) {
if (missingAltText) { if (missingAltText) {
dispatch(openModal({ dispatch(openModal({
modalType: 'CONFIRM_MISSING_ALT_TEXT', modalType: 'CONFIRM_MISSING_ALT_TEXT',
modalProps: {}, modalProps: {},
})); }));
} else if (quoteToPrivate) {
dispatch(openModal({
modalType: 'CONFIRM_PRIVATE_QUOTE_NOTIFY',
modalProps: {},
}));
} else { } else {
dispatch(submitCompose((status) => { dispatch(submitCompose((status) => {
if (props.redirectOnSuccess) { if (props.redirectOnSuccess) {

View File

@ -1,8 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { changeComposeVisibility } from '../../../actions/compose'; import { changeComposeVisibility } from '@/mastodon/actions/compose_typed';
import { openModal, closeModal } from '../../../actions/modal';
import { isUserTouching } from '../../../is_mobile';
import PrivacyDropdown from '../components/privacy_dropdown'; import PrivacyDropdown from '../components/privacy_dropdown';
const mapStateToProps = state => ({ const mapStateToProps = state => ({

View File

@ -19,14 +19,12 @@ const messages = defineMessages({
const Blocks: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { const Blocks: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const intl = useIntl(); const intl = useIntl();
const [domains, setDomains] = useState<string[]>([]); const [domains, setDomains] = useState<string[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
const [next, setNext] = useState<string | undefined>(); const [next, setNext] = useState<string | undefined>();
const hasMore = !!next; const hasMore = !!next;
const columnRef = useRef<ColumnRef>(null); const columnRef = useRef<ColumnRef>(null);
useEffect(() => { useEffect(() => {
setLoading(true);
void apiGetDomainBlocks() void apiGetDomainBlocks()
.then(({ domains, links }) => { .then(({ domains, links }) => {
const next = links.refs.find((link) => link.rel === 'next'); const next = links.refs.find((link) => link.rel === 'next');
@ -40,7 +38,7 @@ const Blocks: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
.catch(() => { .catch(() => {
setLoading(false); setLoading(false);
}); });
}, [setLoading, setDomains, setNext]); }, []);
const handleLoadMore = useCallback(() => { const handleLoadMore = useCallback(() => {
setLoading(true); setLoading(true);

View File

@ -1,6 +1,5 @@
import Trie from 'substring-trie'; import Trie from 'substring-trie';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { assetHost } from 'mastodon/utils/config'; import { assetHost } from 'mastodon/utils/config';
import { autoPlayGif } from '../../initial_state'; import { autoPlayGif } from '../../initial_state';
@ -153,13 +152,9 @@ const emojifyNode = (node, customEmojis) => {
* Legacy emoji processing function. * Legacy emoji processing function.
* @param {string} str * @param {string} str
* @param {object} customEmojis * @param {object} customEmojis
* @param {boolean} force If true, always emojify even if modern emoji is enabled
* @returns {string} * @returns {string}
*/ */
const emojify = (str, customEmojis = {}, force = false) => { const emojify = (str, customEmojis = {}) => {
if (isModernEmojiEnabled() && !force) {
return str;
}
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.innerHTML = str; wrapper.innerHTML = str;

View File

@ -14,8 +14,7 @@ import { uncompress as emojiMartUncompress } from 'emoji-mart/dist/utils/data';
import data from './emoji_data.json'; import data from './emoji_data.json';
import emojiMap from './emoji_map.json'; import emojiMap from './emoji_map.json';
import { unicodeToFilename } from './unicode_to_filename'; import { unicodeToFilename, unicodeToUnifiedName } from './unicode_utils';
import { unicodeToUnifiedName } from './unicode_to_unified_name';
emojiMartUncompress(data); emojiMartUncompress(data);

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