mirror of
https://github.com/mastodon/mastodon.git
synced 2025-11-27 18:10:58 +00:00
Merge branch 'main' into translate-toots
This commit is contained in:
commit
b4dd2a96f1
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
2
.github/actions/setup-javascript/action.yml
vendored
2
.github/actions/setup-javascript/action.yml
vendored
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/renovate.json5
vendored
2
.github/renovate.json5
vendored
|
|
@ -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',
|
||||||
|
|
|
||||||
8
.github/workflows/build-container-image.yml
vendored
8
.github/workflows/build-container-image.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/build-push-pr.yml
vendored
2
.github/workflows/build-push-pr.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
4
.github/workflows/build-releases.yml
vendored
4
.github/workflows/build-releases.yml
vendored
|
|
@ -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}}
|
||||||
|
|
|
||||||
2
.github/workflows/bundler-audit.yml
vendored
2
.github/workflows/bundler-audit.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/check-i18n.yml
vendored
2
.github/workflows/check-i18n.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
4
.github/workflows/chromatic.yml
vendored
4
.github/workflows/chromatic.yml
vendored
|
|
@ -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 }}
|
||||||
|
|
|
||||||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
|
|
@ -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}}'
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
2
.github/workflows/crowdin-download.yml
vendored
2
.github/workflows/crowdin-download.yml
vendored
|
|
@ -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?
|
||||||
|
|
|
||||||
2
.github/workflows/crowdin-upload.yml
vendored
2
.github/workflows/crowdin-upload.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/format-check.yml
vendored
2
.github/workflows/format-check.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/lint-css.yml
vendored
2
.github/workflows/lint-css.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/lint-haml.yml
vendored
2
.github/workflows/lint-haml.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/lint-js.yml
vendored
2
.github/workflows/lint-js.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/lint-ruby.yml
vendored
2
.github/workflows/lint-ruby.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/test-js.yml
vendored
2
.github/workflows/test-js.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/test-migrations.yml
vendored
2
.github/workflows/test-migrations.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
28
.github/workflows/test-ruby.yml
vendored
28
.github/workflows/test-ruby.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
48
CHANGELOG.md
48
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
6
Gemfile
6
Gemfile
|
|
@ -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'
|
||||||
|
|
|
||||||
93
Gemfile.lock
93
Gemfile.lock
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
126
app/javascript/mastodon/components/carousel/carousel.stories.tsx
Normal file
126
app/javascript/mastodon/components/carousel/carousel.stories.tsx
Normal 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: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
244
app/javascript/mastodon/components/carousel/index.tsx
Normal file
244
app/javascript/mastodon/components/carousel/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
54
app/javascript/mastodon/components/carousel/pagination.tsx
Normal file
54
app/javascript/mastodon/components/carousel/pagination.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
app/javascript/mastodon/components/carousel/styles.scss
Normal file
28
app/javascript/mastodon/components/carousel/styles.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>) => {
|
||||||
|
|
|
||||||
|
|
@ -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' />
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,6 @@ export const Linked: Story = {
|
||||||
acct: username,
|
acct: username,
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
return <LinkedDisplayName {...args} displayProps={{ account }} />;
|
return <LinkedDisplayName displayProps={{ account, ...args }} />;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}'
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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}}'
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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}}'
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 ||
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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'}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
100
app/javascript/mastodon/features/audio/visualizer.tsx
Normal file
100
app/javascript/mastodon/features/audio/visualizer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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'>
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 => ({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue
Block a user