Compare commits

..

79 Commits

Author SHA1 Message Date
Claire
b2506cc110 Merge pull request #3276 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to bb6093c315 into stable-4.5
2025-11-13 17:58:54 +01:00
Claire
d18491b7a7 [Glitch] Fix error when sending new posts
Port 058f704c21 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:36:50 +01:00
Claire
13330030cd [Glitch] Fix posts coming from public/hashtag streaming being marked as unquotable
Port 55b9d21537 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:36:21 +01:00
diondiondion
f9012a774c [Glitch] Fix Cmd/Ctrl + Enter not submitting Alt text modal on some browsers
Port 6baa8f2466 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:31:18 +01:00
Echo
375af385e7 [Glitch] Fix deprecation warning in Vite
Port 28b9e9087a to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:30:32 +01:00
diondiondion
7e5224a3c0 [Glitch] Fixes blank screen in browsers that don't support Intl.DisplayNames
Port fa2cc409ce to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:29:41 +01:00
Claire
140d782cba [Glitch] Fix filters not being applied to quotes in detailed view
Port 8a100d84c5 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:28:58 +01:00
Echo
c7a7ce8ce7 [Glitch] Emoji: Load emoji with hash in URL
Port 9ae0464e8f to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:28:15 +01:00
diondiondion
afbe0a4860 [Glitch] Fix scroll shift caused by fetch-all-replies alerts
Port 9eea4479e1 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:27:30 +01:00
diondiondion
febde69d0b [Glitch] Fix dropdown menu not focusing first item when opened via keyboard
Port 30103fd2c8 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:26:57 +01:00
Claire
85b9a5944d [Glitch] Fix prepared quote not being discarded with contents when replying
Port fbe05d42fb to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-13 17:26:34 +01:00
Claire
585827c14f Merge commit 'bb6093c3153f092736e2f25959e3dcdceeb7bce1' into glitch-soc/merge-4.5 2025-11-13 17:22:33 +01:00
Claire
bb6093c315 Bump version to v4.5.1 2025-11-13 17:12:07 +01:00
Claire
058f704c21 Fix error when sending new posts (#36869) 2025-11-13 17:12:07 +01:00
diondiondion
6baa8f2466 Fix Cmd/Ctrl + Enter not submitting Alt text modal on some browsers (#36866) 2025-11-13 17:12:07 +01:00
github-actions[bot]
e742eff044 New Crowdin Translations for stable-4.5 (automated) (#36864)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-11-13 15:46:34 +01:00
Claire
55b9d21537 Fix posts coming from public/hashtag streaming being marked as unquotable (#36860) 2025-11-13 15:24:58 +01:00
Claire
59f0134578 Fix Update importing old previously-unknown activities and treating them as recent ones (#36848) 2025-11-13 15:24:58 +01:00
Echo
28b9e9087a Fix deprecation warning in Vite (#36849) 2025-11-13 15:24:58 +01:00
diondiondion
fa2cc409ce Fixes blank screen in browsers that don't support Intl.DisplayNames (#36847) 2025-11-13 15:24:58 +01:00
Claire
8a100d84c5 Fix filters not being applied to quotes in detailed view (#36843) 2025-11-13 15:24:58 +01:00
Echo
9ae0464e8f Emoji: Load emoji with hash in URL (#36808) 2025-11-13 15:24:58 +01:00
diondiondion
9eea4479e1 Fix scroll shift caused by fetch-all-replies alerts (#36807) 2025-11-13 15:24:58 +01:00
diondiondion
30103fd2c8 Fix dropdown menu not focusing first item when opened via keyboard (#36804) 2025-11-13 15:24:58 +01:00
Claire
a9a7ad62f1 Update dependency rollup from 4.46.2 to 4.46.4 (#36781) 2025-11-13 15:24:58 +01:00
Claire
ea663cf7c7 Fix /api/v1/statuses/:id/context sometimes returing Mastodon-Async-Refresh without result_count (#36779) 2025-11-13 15:24:58 +01:00
Claire
fbe05d42fb Fix prepared quote not being discarded with contents when replying (#36778) 2025-11-13 15:24:58 +01:00
Claire
29ae9c9c4b Add 4.5.x to the list of supported branches (#36761) 2025-11-06 17:12:41 +01:00
Claire
4684d5e69b Merge pull request #3269 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to 26c78392f8
2025-11-06 13:09:16 +01:00
Claire
3ca92c4ae2 Merge commit '26c78392f88afccebb8a88b68bc9994a9ce12648' into glitch-soc/merge-4.5 2025-11-06 12:49:01 +01:00
Claire
81b1a34d96 Merge pull request #3267 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to 048430f4e8
2025-11-06 12:45:08 +01:00
Claire
26c78392f8 Bump version to v4.5.0 (#36732) 2025-11-06 12:39:07 +01:00
Echo
caecc88247 [Glitch] Fix: correctly dismisses announcement when viewed
Port d45b4db1d7 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-06 12:20:19 +01:00
Echo
bed4ca26e2 [Glitch] Add default visualizer for audio upload without poster
Port ef3a95affc to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-06 12:19:58 +01:00
diondiondion
5d108e95d7 [Glitch] Fix spoiler toggle button being able to submit compose form
Port 3e6a9371b0 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-06 12:18:21 +01:00
Claire
8a2d38a47b Merge commit '048430f4e8010672eb667f1ccf2372571cfb97dd' into glitch-soc/merge-4.5 2025-11-06 12:16:55 +01:00
github-actions[bot]
048430f4e8 New Crowdin Translations for stable-4.5 (automated) (#36745)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-11-06 11:37:54 +01:00
Echo
d45b4db1d7 Fix: correctly dismisses announcement when viewed (#36750) 2025-11-06 11:23:46 +01:00
Echo
ef3a95affc Add default visualizer for audio upload without poster (#36734) 2025-11-06 10:34:12 +01:00
diondiondion
3e6a9371b0 Fix spoiler toggle button being able to submit compose form (#36736) 2025-11-06 10:34:12 +01:00
Claire
7ab0cfd637 Merge pull request #3266 from ClearlyClaire/glitch-soc/merge-4.5
Port missing changes to stable-4.5
2025-11-05 11:27:29 +01:00
Claire
b33bcc8be6 Merge pull request #3265 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to e91c764590
2025-11-05 10:52:40 +01:00
Claire
cdad6ee0c9 [Glitch] Change paste-link-to-quote loading state from generic loading bar to compose placeholder
Port cfdd9396c0 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-05 10:09:28 +01:00
Claire
2d958cb909 [Glitch] Change quote action to error instead of insert link in Private Mentions
Port ba498ae779 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-05 10:09:12 +01:00
Echo
949f15e200 [Glitch] Quote Posts: Add notifications for DMs and private posts
Port 5bae08d1ff to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-05 10:09:02 +01:00
Claire
105a2d64a7 [Glitch] Fix Skeleton placeholders being animated when setting to reduce animations is enabled
Port 0b50789c5b to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-05 10:08:49 +01:00
Claire
1c17990413 [Glitch] Fix quote dropdown menu item in detailed status view
Port a978e37f4c to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-05 10:08:37 +01:00
Claire
0a8f96d3be [Glitch] Remove option to disable access to local topic feeds for logged-in users
Port dd708298a8 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-05 10:08:24 +01:00
Claire
1ec8e42dbb [Glitch] Disable paste-link-to-quote flow when composing Private Mentions
Port 6d53ca63d6 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-11-05 10:08:08 +01:00
Claire
7ea3c6a039 Merge commit 'e91c7645905972d9663a0d944b133ff24670bce2' into glitch-soc/merge-4.5 2025-11-05 10:02:33 +01:00
Claire
e91c764590 Bump version to v4.5.0-rc.3 2025-11-05 09:59:00 +01:00
Claire
cfdd9396c0 Change paste-link-to-quote loading state from generic loading bar to compose placeholder (#36695) 2025-11-05 09:59:00 +01:00
Claire
ba498ae779 Change quote action to error instead of insert link in Private Mentions (#36721) 2025-11-05 09:59:00 +01:00
Echo
5bae08d1ff Quote Posts: Add notifications for DMs and private posts (#36696) 2025-11-05 09:59:00 +01:00
Echo
5253527ec4 Add CSS Module support (#36637) 2025-11-05 09:59:00 +01:00
Claire
0b50789c5b Fix Skeleton placeholders being animated when setting to reduce animations is enabled (#36716) 2025-11-05 09:59:00 +01:00
Claire
a978e37f4c Fix quote dropdown menu item in detailed status view (#36704) 2025-11-05 09:59:00 +01:00
Claire
dd708298a8 Remove option to disable access to local topic feeds for logged-in users (#36703) 2025-11-05 09:59:00 +01:00
renovate[bot]
449eb03f11 chore(deps): update dependency sidekiq to v8.0.9 (#36699)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 09:59:00 +01:00
renovate[bot]
1baede0a7c chore(deps): update dependency brakeman to v7.1.1 (#35434)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 09:59:00 +01:00
renovate[bot]
a7ecfc1ca5 fix(deps): update dependency @rails/ujs to v7.1.600 (#36634)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 09:59:00 +01:00
Claire
e62baacfc1 Increase number of quote approval job retries (#36698) 2025-11-05 09:59:00 +01:00
Rachael Wright-Munn
b5a6feb3bf Move "Privacy and reach" from "Public profile" to top-level navigation (#27294) 2025-11-05 09:59:00 +01:00
Claire
05964f571b Prevent creation of Private Mentions quoting someone who is not mentioned (#36689) 2025-11-05 09:59:00 +01:00
Claire
16a54f7158 Fix issuance of quote approval for remote private statuses (#36693) 2025-11-05 09:59:00 +01:00
Claire
6d53ca63d6 Disable paste-link-to-quote flow when composing Private Mentions (#36690) 2025-11-05 09:59:00 +01:00
renovate[bot]
93acfdd7d3 chore(deps): update dependency irb to v1.15.3 (#36682)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 09:59:00 +01:00
renovate[bot]
a209b8e544 chore(deps): update dependency rubyzip to v3.2.2 (#36687)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 09:59:00 +01:00
Claire
ca0c5e7a79 Merge pull request #3258 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to af4c372ab2 into stable-4.5
2025-10-31 16:35:41 +01:00
diondiondion
10a81a2f43 [Glitch] Fix initially selected language in Rules panel, hide selector when no alternative translations exist
Port aa579ce286 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-31 16:12:38 +01:00
diondiondion
9f8fce3c47 [Glitch] Show error when submitting empty post rather than failing silently
Port 214d59bd37 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-31 16:12:03 +01:00
Claire
5f5e6ca031 Merge commit 'af4c372ab22b636742833592235d18418604fcc1' into glitch-soc/merge-4.5 2025-10-31 16:04:05 +01:00
Claire
af4c372ab2 Bump version to v4.5.0-rc.2 2025-10-31 16:01:06 +01:00
diondiondion
aa579ce286 Fix initially selected language in Rules panel, hide selector when no alternative translations exist (#36672) 2025-10-31 16:01:06 +01:00
github-actions[bot]
adfabf8c80 New Crowdin Translations for stable-4.5 (automated) (#36670)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-10-31 14:51:18 +01:00
renovate[bot]
ea710df180 chore(deps): update dependency axios to v1.13.1 (#36633)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-31 14:09:36 +01:00
renovate[bot]
e1b6e28829 chore(deps): update dependency libvips to v8.17.3 (#36654)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-31 14:09:36 +01:00
diondiondion
214d59bd37 Show error when submitting empty post rather than failing silently (#36650) 2025-10-31 14:09:36 +01:00
Claire
e4291e9b05 Fix SMTP configuration with mail 2.9.0 (#36646) 2025-10-31 14:09:36 +01:00
890 changed files with 16534 additions and 18252 deletions

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
'customManagers:dockerfileVersions',
':labels(dependencies)',
':prConcurrentLimitNone', // Remove limit for open PRs at any time.
':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour.
':enableVulnerabilityAlertsWithLabel(security)',
],
rebaseWhen: 'conflicted',
@@ -22,6 +23,8 @@
// Require Dependency Dashboard Approval for major version bumps of these node packages
matchManagers: ['npm'],
matchPackageNames: [
'tesseract.js', // Requires code changes
// react-router: Requires manual upgrade
'history',
'react-router-dom',

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,51 +1,31 @@
name: 'Chromatic'
permissions:
contents: read
on:
push:
branches-ignore:
- renovate/*
- stable-*
paths:
- 'package.json'
- 'yarn.lock'
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
- '**/*.tsx'
- '**/*.css'
- '**/*.scss'
- '.github/workflows/chromatic.yml'
jobs:
pathcheck:
name: Check for relevant changes
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.filter.outputs.src }}
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
src:
- 'package.json'
- 'yarn.lock'
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
- '**/*.tsx'
- '**/*.css'
- '**/*.scss'
- '.github/workflows/chromatic.yml'
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
needs: pathcheck
if: github.repository == 'mastodon/mastodon' && needs.pathcheck.outputs.changed == 'true'
if: github.repository == 'mastodon/mastodon'
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
@@ -53,10 +33,9 @@ jobs:
run: yarn build-storybook
- name: Run Chromatic
uses: chromaui/action@v13
uses: chromaui/action@v12
with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
zip: true
storybookBuildDir: 'storybook-static'
exitZeroOnChanges: false # Fail workflow if changes are found
autoAcceptChanges: 'main' # Auto-accept changes on main branch only

View File

@@ -31,11 +31,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# 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).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v4
uses: github/codeql-action/autobuild@v3
# 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
@@ -61,6 +61,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v3
with:
category: '/language:${{matrix.language}}'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
.nvmrc
View File

@@ -1 +1 @@
24.11
24.10

View File

@@ -95,7 +95,6 @@ AUTHORS.md
# Ignore glitch-soc vendored CSS reset
app/javascript/flavours/glitch/styles/reset.scss
app/javascript/flavours/glitch/styles_new/mastodon/reset.scss
# Ignore win95 theme
app/javascript/styles/win95.scss

View File

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

View File

@@ -1,2 +1,2 @@
<html class="no-reduce-motion theme-light">
<html class="no-reduce-motion">
</html>

View File

@@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.12.1'
const PACKAGE_VERSION = '2.11.3'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
@@ -205,7 +205,6 @@ async function resolveMainClient(event) {
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {

View File

@@ -538,7 +538,7 @@ and provided thanks to the work of the following contributors:
* [Drew Schuster](mailto:dtschust@gmail.com)
* [Dryusdan](mailto:dryusdan@dryusdan.fr)
* [Eai](mailto:eai@mizle.net)
* [Eashwar Ranganathan](mailto:eashwar@eashwar.com)
* [Eashwar Ranganathan](mailto:eranganathan@lyft.com)
* [Ed Knutson](mailto:knutsoned@gmail.com)
* [Elizabeth Martín Campos](mailto:me@elizabeth.sh)
* [Elizabeth Myers](mailto:elizabeth@interlinked.me)

View File

@@ -2,30 +2,9 @@
All notable changes to this project will be documented in this file.
## [4.5.2] - 2025-11-20
### Changed
- Change private quote education modal to not show up on self-quotes (#36926 by @ClearlyClaire)
### Fixed
- Fix missing fallback link in CW-only quote posts (#36963 by @ClearlyClaire)
- Fix statuses without text being hidden while loading (#36962 by @ClearlyClaire)
- Fix `g` + `h` keyboard shortcut not working when a post is focused (#36935 by @diondiondion)
- Fix quoting overwriting current content warning (#36934 by @ClearlyClaire)
- Fix scroll-to-status in threaded view being unreliable (#36927 by @ClearlyClaire)
- Fix path resolution for emoji worker (#36897 by @ChaosExAnima)
- Fix `tootctl upgrade storage-schema` failing with `ArgumentError` (#36914 by @shugo)
- Fix cross-origin handling of CSS modules (#36890 by @ClearlyClaire)
- Fix error with remote tags including percent signs (#36886 and #36925 by @ChaosExAnima and @ClearlyClaire)
- Fix bogus quote approval policy not always being replaced correctly (#36885 by @ClearlyClaire)
- Fix hashtag completion not being inserted correctly (#36884 by @ClearlyClaire)
- Fix Cmd/Ctrl + Enter in the composer triggering confirmation dialog action (#36870 by @diondiondion)
## [4.5.1] - 2025-11-13
### Fixed
### Fixes
- Fix Cmd/Ctrl + Enter not submitting Alt text modal on some browsers (#36866 by @diondiondion)
- Fix posts coming from public/hashtag streaming being marked as unquotable (#36860 and #36869 by @ClearlyClaire)

12
Gemfile
View File

@@ -13,7 +13,7 @@ gem 'haml-rails', '~>3.0'
gem 'pg', '~> 1.5'
gem 'pghero'
gem 'aws-sdk-core', require: false
gem 'aws-sdk-core', '< 3.216.0', require: false # TODO: https://github.com/mastodon/mastodon/pull/34173#issuecomment-2733378873
gem 'aws-sdk-s3', '~> 1.123', require: false
gem 'blurhash', '~> 0.1'
gem 'fog-core', '<= 2.6.0'
@@ -24,7 +24,7 @@ gem 'ruby-vips', '~> 2.2', require: false
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.8'
gem 'bootsnap', '~> 1.19.0', require: false
gem 'bootsnap', '~> 1.18.0', require: false
gem 'browser'
gem 'charlock_holmes', '~> 0.7.7'
gem 'chewy', '~> 7.3'
@@ -40,7 +40,7 @@ gem 'net-ldap', '~> 0.18'
gem 'omniauth', '~> 2.0'
gem 'omniauth-cas', '~> 3.0.0.beta.1'
gem 'omniauth_openid_connect', '~> 0.8.0'
gem 'omniauth-rails_csrf_protection', '~> 2.0'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
gem 'omniauth-saml', '~> 2.0'
gem 'color_diff', '~> 0.1'
@@ -71,7 +71,7 @@ gem 'oj', '~> 3.14'
gem 'ox', '~> 2.14'
gem 'parslet'
gem 'premailer-rails'
gem 'public_suffix', '~> 7.0'
gem 'public_suffix', '~> 6.0'
gem 'pundit', '~> 2.3'
gem 'rack-attack', '~> 6.6'
gem 'rack-cors', require: 'rack/cors'
@@ -114,7 +114,7 @@ group :opentelemetry do
gem 'opentelemetry-instrumentation-http', '~> 0.27.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-pg', '~> 0.34.0', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.32.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false
@@ -138,7 +138,7 @@ group :test do
# Browser integration testing
gem 'capybara', '~> 3.39'
gem 'capybara-playwright-driver'
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
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
# Used to reset the database between system tests
gem 'database_cleaner-active_record'

View File

@@ -86,8 +86,8 @@ GEM
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0)
android_key_attestation (0.3.0)
annotaterb (4.20.0)
@@ -96,20 +96,17 @@ GEM
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.4.0)
aws-partitions (1.1190.0)
aws-sdk-core (3.239.2)
aws-partitions (1.1168.0)
aws-sdk-core (3.215.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.206.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.177.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -129,14 +126,14 @@ GEM
binding_of_caller (1.0.1)
debug_inspector (>= 1.2.0)
blurhash (0.1.8)
bootsnap (1.19.0)
bootsnap (1.18.6)
msgpack (~> 1.2)
brakeman (7.1.1)
racc
browser (6.2.0)
builder (3.3.0)
bundler-audit (0.9.3)
bundler (>= 1.2.0)
bundler-audit (0.9.2)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
capybara (3.40.0)
addressable
@@ -167,7 +164,7 @@ GEM
cocoon (1.2.15)
color_diff (0.1)
concurrent-ruby (1.3.5)
connection_pool (2.5.5)
connection_pool (2.5.4)
cose (1.3.1)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0)
@@ -182,7 +179,7 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0)
database_cleaner-core (2.0.1)
date (3.5.0)
date (3.4.1)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
@@ -234,7 +231,7 @@ GEM
excon (1.3.0)
logger
fabrication (3.0.0)
faker (3.5.3)
faker (3.5.2)
i18n (>= 1.8.11, < 2)
faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5)
@@ -282,7 +279,7 @@ GEM
rake (>= 13)
googleapis-common-protos-types (1.22.0)
google-protobuf (~> 4.26)
haml (6.4.0)
haml (6.3.0)
temple (>= 0.8.2)
thor
tilt
@@ -291,7 +288,7 @@ GEM
activesupport (>= 5.1)
haml (>= 4.0.6)
railties (>= 5.1)
haml_lint (0.67.0)
haml_lint (0.66.0)
haml (>= 5.0)
parallel (~> 1.10)
rainbow
@@ -324,14 +321,13 @@ GEM
rainbow (>= 2.0.0)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
i18n-tasks (1.1.2)
i18n-tasks (1.0.15)
activesupport (>= 4.0.2)
ast (>= 2.1.0)
erubi
highline (>= 3.0.0)
highline (>= 2.0.0)
i18n
parser (>= 3.2.2.1)
prism
rails-i18n
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.8, >= 1.8.1)
@@ -350,7 +346,7 @@ GEM
azure-blob (~> 0.5.2)
hashie (~> 5.0)
jmespath (1.6.2)
json (2.16.0)
json (2.15.1)
json-canonicalization (1.0.0)
json-jwt (1.17.0)
activesupport (>= 4.2)
@@ -447,7 +443,7 @@ GEM
mime-types-data (3.2025.0924)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.26.2)
minitest (5.26.0)
msgpack (1.8.0)
multi_json (1.17.0)
mutex_m (0.3.0)
@@ -469,7 +465,7 @@ GEM
nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
oj (3.16.12)
oj (3.16.11)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.4)
@@ -481,7 +477,7 @@ GEM
addressable (~> 2.8)
nokogiri (~> 1.12)
omniauth (~> 2.1)
omniauth-rails_csrf_protection (2.0.0)
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth-saml (2.2.4)
@@ -516,9 +512,9 @@ GEM
opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.10)
opentelemetry-semantic_conventions
opentelemetry-helpers-sql (0.3.0)
opentelemetry-helpers-sql (0.2.0)
opentelemetry-api (~> 1.7)
opentelemetry-helpers-sql-processor (0.3.1)
opentelemetry-helpers-sql-obfuscation (0.4.0)
opentelemetry-common (~> 0.21)
opentelemetry-instrumentation-action_mailer (0.6.1)
opentelemetry-instrumentation-active_support (~> 0.10)
@@ -542,19 +538,19 @@ GEM
opentelemetry-registry (~> 0.1)
opentelemetry-instrumentation-concurrent_ruby (0.24.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-excon (0.26.1)
opentelemetry-instrumentation-excon (0.26.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-faraday (0.30.1)
opentelemetry-instrumentation-faraday (0.30.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-http (0.27.1)
opentelemetry-instrumentation-http (0.27.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-http_client (0.26.1)
opentelemetry-instrumentation-http_client (0.26.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-net_http (0.26.1)
opentelemetry-instrumentation-net_http (0.26.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-pg (0.34.1)
opentelemetry-instrumentation-pg (0.32.0)
opentelemetry-helpers-sql
opentelemetry-helpers-sql-processor
opentelemetry-helpers-sql-obfuscation
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-rack (0.29.0)
opentelemetry-instrumentation-base (~> 0.25)
@@ -569,7 +565,7 @@ GEM
opentelemetry-instrumentation-concurrent_ruby (~> 0.23)
opentelemetry-instrumentation-redis (0.28.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-sidekiq (0.28.1)
opentelemetry-instrumentation-sidekiq (0.28.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-registry (0.4.0)
opentelemetry-api (~> 1.1)
@@ -585,7 +581,7 @@ GEM
ox (2.14.23)
bigdecimal (>= 3.0)
parallel (1.27.0)
parser (3.3.10.0)
parser (3.3.9.0)
ast (~> 2.4.1)
racc
parslet (2.0.0)
@@ -594,7 +590,7 @@ GEM
pg (1.6.2)
pghero (3.7.0)
activerecord (>= 7.1)
playwright-ruby-client (1.56.0)
playwright-ruby-client (1.55.0)
concurrent-ruby (>= 1.1.6)
mime-types (>= 3.0)
pp (0.6.3)
@@ -608,8 +604,8 @@ GEM
net-smtp
premailer (~> 1.7, >= 1.7.9)
prettyprint (0.2.0)
prism (1.6.0)
prometheus_exporter (2.3.1)
prism (1.5.2)
prometheus_exporter (2.3.0)
webrick
propshaft (1.3.1)
actionpack (>= 7.0.0)
@@ -618,7 +614,7 @@ GEM
psych (5.2.6)
date
stringio
public_suffix (7.0.0)
public_suffix (6.0.2)
puma (7.1.0)
nio4r (~> 2.0)
pundit (2.5.2)
@@ -638,7 +634,7 @@ GEM
faraday-follow_redirects
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-protection (4.2.1)
rack-protection (4.1.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
@@ -672,7 +668,7 @@ GEM
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.1.0)
rails-i18n (8.0.2)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.0.3)
@@ -685,7 +681,7 @@ GEM
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rake (13.3.0)
rdf (3.3.4)
bcp47_spec (~> 0.2)
bigdecimal (~> 3.1, >= 3.1.5)
@@ -706,7 +702,7 @@ GEM
redis-client (0.26.1)
connection_pool
regexp_parser (2.11.3)
reline (0.6.3)
reline (0.6.2)
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
@@ -717,10 +713,10 @@ GEM
rotp (6.3.0)
rouge (4.6.1)
rpam2 (4.0.2)
rqrcode (3.1.1)
rqrcode (3.1.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.1)
rqrcode_core (2.0.0)
rspec (3.13.1)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
@@ -749,7 +745,7 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
rspec-support (3.13.6)
rubocop (1.81.7)
rubocop (1.81.6)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -760,7 +756,7 @@ GEM
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.48.0)
rubocop-ast (1.47.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-capybara (2.22.1)
@@ -779,10 +775,10 @@ GEM
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.8.0)
rubocop-rspec (3.7.0)
lint_roller (~> 1.1)
rubocop (~> 1.81)
rubocop-rspec_rails (2.32.0)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec_rails (2.31.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec (~> 3.5)
@@ -807,9 +803,9 @@ GEM
activerecord (>= 4.0.0)
railties (>= 4.0.0)
securerandom (0.4.1)
shoulda-matchers (7.0.1)
activesupport (>= 7.1)
sidekiq (8.0.10)
shoulda-matchers (6.5.0)
activesupport (>= 5.2.0)
sidekiq (8.0.9)
connection_pool (>= 2.5.0)
json (>= 2.9.0)
logger (>= 1.6.2)
@@ -839,9 +835,9 @@ GEM
stackprof (0.2.27)
starry (0.2.0)
base64
stoplight (5.6.0)
stoplight (5.4.0)
zeitwerk
stringio (3.1.8)
stringio (3.1.7)
strong_migrations (2.5.1)
activerecord (>= 7.1)
swd (2.0.3)
@@ -887,7 +883,7 @@ GEM
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.1.1)
uri (1.0.4)
useragent (0.16.11)
validate_url (1.0.15)
activemodel (>= 3.0.0)
@@ -915,7 +911,7 @@ GEM
activesupport
faraday (~> 2.0)
faraday-follow_redirects
webmock (3.26.1)
webmock (3.26.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -937,12 +933,12 @@ DEPENDENCIES
active_model_serializers (~> 0.10)
addressable (~> 2.8)
annotaterb (~> 4.13)
aws-sdk-core
aws-sdk-core (< 3.216.0)
aws-sdk-s3 (~> 1.123)
better_errors (~> 2.9)
binding_of_caller (~> 1.0)
blurhash (~> 0.1)
bootsnap (~> 1.19.0)
bootsnap (~> 1.18.0)
brakeman (~> 7.0)
browser
bundler-audit (~> 0.9)
@@ -1009,7 +1005,7 @@ DEPENDENCIES
oj (~> 3.14)
omniauth (~> 2.0)
omniauth-cas (~> 3.0.0.beta.1)
omniauth-rails_csrf_protection (~> 2.0)
omniauth-rails_csrf_protection (~> 1.0)
omniauth-saml (~> 2.0)
omniauth_openid_connect (~> 0.8.0)
opentelemetry-api (~> 1.7.0)
@@ -1022,7 +1018,7 @@ DEPENDENCIES
opentelemetry-instrumentation-http (~> 0.27.0)
opentelemetry-instrumentation-http_client (~> 0.26.0)
opentelemetry-instrumentation-net_http (~> 0.26.0)
opentelemetry-instrumentation-pg (~> 0.34.0)
opentelemetry-instrumentation-pg (~> 0.32.0)
opentelemetry-instrumentation-rack (~> 0.29.0)
opentelemetry-instrumentation-rails (~> 0.39.0)
opentelemetry-instrumentation-redis (~> 0.28.0)
@@ -1032,11 +1028,11 @@ DEPENDENCIES
parslet
pg (~> 1.5)
pghero
playwright-ruby-client (= 1.56.0)
playwright-ruby-client (= 1.55.0)
premailer-rails
prometheus_exporter (~> 2.2)
propshaft
public_suffix (~> 7.0)
public_suffix (~> 6.0)
puma (~> 7.0)
pundit (~> 2.3)
rack-attack (~> 6.6)

View File

@@ -22,7 +22,7 @@ class ActivityPub::LikesController < ActivityPub::BaseController
def set_status
@status = @account.statuses.find(params[:status_id])
authorize @status, :show?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end

View File

@@ -24,7 +24,7 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
return not_found unless @quote.status.present? && @quote.quoted_status.present?
authorize @quote.quoted_status, :show?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end
end

View File

@@ -25,7 +25,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
def set_status
@status = @account.statuses.find(params[:status_id])
authorize @status, :show?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end

View File

@@ -22,7 +22,7 @@ class ActivityPub::SharesController < ActivityPub::BaseController
def set_status
@status = @account.statuses.find(params[:status_id])
authorize @status, :show?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end

View File

@@ -5,15 +5,6 @@ module Admin
def index
authorize :custom_emoji, :index?
# If filtering by local emojis, remove by_domain filter.
params.delete(:by_domain) if params[:local].present?
# If filtering by domain, ensure remote filter is set.
if params[:by_domain].present?
params.delete(:local)
params[:remote] = '1'
end
@custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page])
@form = Form::CustomEmojiBatch.new
end

View File

@@ -9,7 +9,7 @@ module Admin
@site_upload.destroy!
redirect_back_or_to admin_settings_path, notice: I18n.t('admin.site_uploads.destroyed_msg')
redirect_back fallback_location: admin_settings_path, notice: I18n.t('admin.site_uploads.destroyed_msg')
end
private

View File

@@ -1,12 +1,10 @@
# frozen_string_literal: true
class Api::V1::AnnualReportsController < Api::BaseController
include AsyncRefreshesConcern
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, except: [:read, :generate]
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:read, :generate]
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
before_action :require_user!
before_action :set_annual_report, only: [:show, :read]
before_action :set_annual_report, except: :index
def index
with_read_replica do
@@ -30,59 +28,14 @@ class Api::V1::AnnualReportsController < Api::BaseController
relationships: @relationships
end
def state
render json: { state: report_state }
end
def generate
return render_empty unless year == AnnualReport.current_campaign
return render_empty if GeneratedAnnualReport.exists?(account_id: current_account.id, year: year)
async_refresh = AsyncRefresh.new(refresh_key)
if async_refresh.running?
add_async_refresh_header(async_refresh, retry_seconds: 2)
return head 202
end
add_async_refresh_header(AsyncRefresh.create(refresh_key), retry_seconds: 2)
GenerateAnnualReportWorker.perform_async(current_account.id, year)
head 202
end
def read
@annual_report.view!
render_empty
end
def refresh_key
"wrapstodon:#{current_account.id}:#{year}"
end
private
def report_state
return 'available' if GeneratedAnnualReport.exists?(account_id: current_account.id, year: year)
async_refresh = AsyncRefresh.new(refresh_key)
if async_refresh.running?
add_async_refresh_header(async_refresh, retry_seconds: 2)
'generating'
elsif AnnualReport.current_campaign == year && AnnualReport.new(current_account, year).eligible?
'eligible'
else
'ineligible'
end
end
def year
params[:id]&.to_i
end
def set_annual_report
@annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: year)
@annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: params[:id])
end
end

View File

@@ -17,7 +17,7 @@ class Api::V1::Polls::VotesController < Api::BaseController
def set_poll
@poll = Poll.find(params[:poll_id])
authorize @poll.status, :show?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end

View File

@@ -17,7 +17,7 @@ class Api::V1::PollsController < Api::BaseController
def set_poll
@poll = Poll.find(params[:id])
authorize @poll.status, :show?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end

View File

@@ -10,7 +10,7 @@ class Api::V1::Statuses::BaseController < Api::BaseController
def set_status
@status = Status.find(params[:status_id])
authorize @status, :show?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end
end

View File

@@ -23,7 +23,7 @@ class Api::V1::Statuses::BookmarksController < Api::V1::Statuses::BaseController
bookmark&.destroy!
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, bookmarks_map: { @status.id => false })
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end
end

View File

@@ -25,7 +25,7 @@ class Api::V1::Statuses::FavouritesController < Api::V1::Statuses::BaseControlle
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }, attributes_map: { @status.id => { favourites_count: count } })
render json: @status, serializer: REST::StatusSerializer, relationships: relationships
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end
end

View File

@@ -36,7 +36,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }, attributes_map: { @reblog.id => { reblogs_count: count } })
render json: @reblog, serializer: REST::StatusSerializer, relationships: relationships
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end
@@ -45,7 +45,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController
def set_reblog
@reblog = Status.find(params[:status_id])
authorize @reblog, :show?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end

View File

@@ -128,11 +128,10 @@ class Api::V1::StatusesController < Api::BaseController
@status = Status.where(account: current_account).find(params[:id])
authorize @status, :destroy?
json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true
@status.discard_with_reblogs
StatusPin.find_by(status: @status)&.destroy
@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) })
@@ -148,7 +147,7 @@ class Api::V1::StatusesController < Api::BaseController
def set_status
@status = Status.find(params[:id])
authorize @status, :show?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end

View File

@@ -1,115 +0,0 @@
# frozen_string_literal: true
class Api::V1Alpha::CollectionsController < Api::BaseController
include Authorization
DEFAULT_COLLECTIONS_LIMIT = 40
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
render json: { error: ValidationErrorFormatter.new(e).as_json }, status: 422
end
before_action :check_feature_enabled
before_action -> { authorize_if_got_token! :read, :'read:collections' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :write, :'write:collections' }, only: [:create, :update, :destroy]
before_action :require_user!, only: [:create, :update, :destroy]
before_action :set_account, only: [:index]
before_action :set_collections, only: [:index]
before_action :set_collection, only: [:show, :update, :destroy]
after_action :insert_pagination_headers, only: [:index]
after_action :verify_authorized
def index
cache_if_unauthenticated!
authorize Collection, :index?
render json: @collections, each_serializer: REST::BaseCollectionSerializer
end
def show
cache_if_unauthenticated!
authorize @collection, :show?
render json: @collection, serializer: REST::CollectionSerializer
end
def create
authorize Collection, :create?
@collection = CreateCollectionService.new.call(collection_creation_params, current_user.account)
render json: @collection, serializer: REST::CollectionSerializer
end
def update
authorize @collection, :update?
@collection.update!(collection_update_params) # TODO: Create a service for this to federate changes
render json: @collection, serializer: REST::CollectionSerializer
end
def destroy
authorize @collection, :destroy?
@collection.destroy
head 200
end
private
def set_account
@account = Account.find(params[:account_id])
end
def set_collections
@collections = @account.collections
.with_tag
.with_item_count
.order(created_at: :desc)
.offset(offset_param)
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
end
def set_collection
@collection = Collection.find(params[:id])
end
def collection_creation_params
params.permit(:name, :description, :sensitive, :discoverable, :tag_name, account_ids: [])
end
def collection_update_params
params.permit(:name, :description, :sensitive, :discoverable, :tag_name)
end
def check_feature_enabled
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
end
def next_path
return unless records_continue?
api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT)))
end
def prev_path
return if offset_param.zero?
api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param - limit_param(DEFAULT_COLLECTIONS_LIMIT)))
end
def records_continue?
((offset_param * limit_param(DEFAULT_COLLECTIONS_LIMIT)) + @collections.size) < @account.collections.size
end
def offset_param
params[:offset].to_i
end
end

View File

@@ -30,7 +30,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController
def set_status
@status = Status.find(params[:id])
authorize @status, :show?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end
end

View File

@@ -135,7 +135,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
@accept_token = session[:accept_token] = SecureRandom.hex
@invite_code = invite_code
render :rules
set_locale { render :rules }
end
def is_flashing_format? # rubocop:disable Naming/PredicatePrefix

View File

@@ -21,7 +21,7 @@ class AuthorizeInteractionsController < ApplicationController
def set_resource
@resource = located_resource
authorize(@resource, :show?) if @resource.is_a?(Status)
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end

View File

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

View File

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

View File

@@ -34,7 +34,7 @@ class MediaController < ApplicationController
def verify_permitted_status!
authorize @media_attachment.status, :show?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end

View File

@@ -62,7 +62,7 @@ class StatusesController < ApplicationController
def set_status
@status = @account.statuses.find(params[:id])
authorize @status, :show?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue Mastodon::NotPermittedError
not_found
end

View File

@@ -1,23 +0,0 @@
# frozen_string_literal: true
class WrapstodonController < ApplicationController
include WebAppControllerConcern
include Authorization
include AccountOwnedConcern
vary_by 'Accept, Accept-Language, Cookie'
before_action :set_generated_annual_report
skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode?
def show
expires_in 10.seconds, public: true if current_account.nil?
end
private
def set_generated_annual_report
@generated_annual_report = GeneratedAnnualReport.find_by!(account: @account, year: params[:year], share_key: params[:share_key])
end
end

View File

@@ -153,8 +153,11 @@ module ApplicationHelper
tag.meta(content: content, property: property)
end
def html_classes
def body_classes
output = []
output << content_for(:body_classes)
output << "flavour-#{current_flavour.parameterize}"
output << "skin-#{current_skin.parameterize}"
output << 'system-font' if current_account&.user&.setting_system_font_ui
output << 'custom-scrollbars' unless current_account&.user&.setting_system_scrollbars_ui
output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion')
@@ -162,12 +165,6 @@ module ApplicationHelper
output.compact_blank.join(' ')
end
def body_classes
output = []
output << content_for(:body_classes)
output.compact_blank.join(' ')
end
def cdn_host
Rails.configuration.action_controller.asset_host
end

View File

@@ -35,7 +35,7 @@ module LanguagesHelper
cy: ['Welsh', 'Cymraeg'].freeze,
da: ['Danish', 'dansk'].freeze,
de: ['German', 'Deutsch'].freeze,
dv: ['Divehi', 'ދިވެހި'].freeze,
dv: ['Divehi', 'Dhivehi'].freeze,
dz: ['Dzongkha', 'རྫོང་ཁ'].freeze,
ee: ['Ewe', 'Eʋegbe'].freeze,
el: ['Greek', 'Ελληνικά'].freeze,
@@ -100,7 +100,7 @@ module LanguagesHelper
lo: ['Lao', 'ລາວ'].freeze,
lt: ['Lithuanian', 'lietuvių kalba'].freeze,
lu: ['Luba-Katanga', 'Tshiluba'].freeze,
lv: ['Latvian', 'Latviski'].freeze,
lv: ['Latvian', 'latviešu valoda'].freeze,
mg: ['Malagasy', 'fiteny malagasy'].freeze,
mh: ['Marshallese', 'Kajin M̧ajeļ'].freeze,
mi: ['Māori', 'te reo Māori'].freeze,

View File

@@ -1,16 +0,0 @@
# frozen_string_literal: true
module WrapstodonHelper
def render_wrapstodon_share_data(report)
json = ActiveModelSerializers::SerializableResource.new(
AnnualReportsPresenter.new([report]),
serializer: REST::AnnualReportsSerializer,
scope: nil,
scope_name: :current_user
).to_json
# rubocop:disable Rails/OutputSafety
content_tag(:script, json_escape(json).html_safe, type: 'application/json', id: 'wrapstodon-data')
# rubocop:enable Rails/OutputSafety
end
end

View File

@@ -1,49 +1,31 @@
# frozen_string_literal: true
class DateOfBirthInput < SimpleForm::Inputs::Base
OPTIONS = {
day: { autocomplete: 'bday-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' },
month: { autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' },
year: { autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' },
}.freeze
OPTIONS = [
{ autocomplete: 'bday-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' }.freeze,
{ autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' }.freeze,
{ autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' }.freeze,
].freeze
def input(wrapper_options = nil)
merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
merged_input_options[:inputmode] = 'numeric'
safe_join(
ordered_options.map do |option|
options = merged_input_options
.merge(OPTIONS[option])
.merge(
id: generate_id(option),
'aria-label': I18n.t("simple_form.labels.user.date_of_birth_#{param_for(option)}"),
value: values[option]
)
@builder.text_field("#{attribute_name}(#{param_for(option)})", options)
end
)
values = (object.public_send(attribute_name) || '').split('.')
safe_join(Array.new(3) do |index|
options = merged_input_options.merge(OPTIONS[index]).merge id: generate_id(index), 'aria-label': I18n.t("simple_form.labels.user.date_of_birth_#{index + 1}i"), value: values[index]
@builder.text_field("#{attribute_name}(#{index + 1}i)", options)
end)
end
def label_target
"#{attribute_name}_#{param_for(ordered_options.first)}"
"#{attribute_name}_1i"
end
private
def ordered_options
I18n.t('date.order').map(&:to_sym)
end
def generate_id(option)
"#{object_name}_#{attribute_name}_#{param_for(option)}"
end
def param_for(option)
"#{ActionView::Helpers::DateTimeSelector::POSITION[option]}i"
end
def values
Date._parse((object.public_send(attribute_name) || '').to_s).transform_keys(mon: :month, mday: :day)
def generate_id(index)
"#{object_name}_#{attribute_name}_#{index + 1}i"
end
end

View File

@@ -1,7 +1,7 @@
import { createRoot } from 'react-dom/client';
import Rails from '@rails/ujs';
import { decode, ValidationError } from 'blurhash';
import { on } from 'delegated-events';
import ready from '../mastodon/ready';
@@ -24,9 +24,10 @@ const setAnnouncementEndsAttributes = (target: HTMLInputElement) => {
}
};
on(
'change',
Rails.delegate(
document,
'input[type="datetime-local"]#announcement_starts_at',
'change',
({ target }) => {
if (target instanceof HTMLInputElement)
setAnnouncementEndsAttributes(target);
@@ -62,7 +63,7 @@ const hideSelectAll = () => {
if (hiddenField) hiddenField.value = '0';
};
on('change', '#batch_checkbox_all', ({ target }) => {
Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => {
if (!(target instanceof HTMLInputElement)) return;
const selectAllMatchingElement = document.querySelector(
@@ -84,7 +85,7 @@ on('change', '#batch_checkbox_all', ({ target }) => {
}
});
on('click', '.batch-table__select-all button', () => {
Rails.delegate(document, '.batch-table__select-all button', 'click', () => {
const hiddenField = document.querySelector<HTMLInputElement>(
'#select_all_matching',
);
@@ -112,7 +113,7 @@ on('click', '.batch-table__select-all button', () => {
}
});
on('change', batchCheckboxClassName, () => {
Rails.delegate(document, batchCheckboxClassName, 'change', () => {
const checkAllElement = document.querySelector<HTMLInputElement>(
'input#batch_checkbox_all',
);
@@ -139,9 +140,14 @@ on('change', batchCheckboxClassName, () => {
}
});
on('change', '.filter-subset--with-select select', ({ target }) => {
if (target instanceof HTMLSelectElement) target.form?.submit();
});
Rails.delegate(
document,
'.filter-subset--with-select select',
'change',
({ target }) => {
if (target instanceof HTMLSelectElement) target.form?.submit();
},
);
const onDomainBlockSeverityChange = (target: HTMLSelectElement) => {
const rejectMediaDiv = document.querySelector(
@@ -162,11 +168,11 @@ const onDomainBlockSeverityChange = (target: HTMLSelectElement) => {
}
};
on('change', '#domain_block_severity', ({ target }) => {
Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => {
if (target instanceof HTMLSelectElement) onDomainBlockSeverityChange(target);
});
function onEnableBootstrapTimelineAccountsChange(target: HTMLInputElement) {
const onEnableBootstrapTimelineAccountsChange = (target: HTMLInputElement) => {
const bootstrapTimelineAccountsField =
document.querySelector<HTMLInputElement>(
'#form_admin_settings_bootstrap_timeline_accounts',
@@ -188,11 +194,12 @@ function onEnableBootstrapTimelineAccountsChange(target: HTMLInputElement) {
);
}
}
}
};
on(
'change',
Rails.delegate(
document,
'#form_admin_settings_enable_bootstrap_timeline_accounts',
'change',
({ target }) => {
if (target instanceof HTMLInputElement)
onEnableBootstrapTimelineAccountsChange(target);
@@ -232,11 +239,11 @@ const onChangeRegistrationMode = (target: HTMLSelectElement) => {
});
};
function convertUTCDateTimeToLocal(value: string) {
const convertUTCDateTimeToLocal = (value: string) => {
const date = new Date(value + 'Z');
const twoChars = (x: number) => x.toString().padStart(2, '0');
return `${date.getFullYear()}-${twoChars(date.getMonth() + 1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`;
}
};
function convertLocalDatetimeToUTC(value: string) {
const date = new Date(value);
@@ -244,9 +251,14 @@ function convertLocalDatetimeToUTC(value: string) {
return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6);
}
on('change', '#form_admin_settings_registrations_mode', ({ target }) => {
if (target instanceof HTMLSelectElement) onChangeRegistrationMode(target);
});
Rails.delegate(
document,
'#form_admin_settings_registrations_mode',
'change',
({ target }) => {
if (target instanceof HTMLSelectElement) onChangeRegistrationMode(target);
},
);
async function mountReactComponent(element: Element) {
const componentName = element.getAttribute('data-admin-component');
@@ -293,7 +305,7 @@ ready(() => {
if (registrationMode) onChangeRegistrationMode(registrationMode);
const checkAllElement = document.querySelector<HTMLInputElement>(
'#batch_checkbox_all',
'input#batch_checkbox_all',
);
if (checkAllElement) {
const allCheckboxes = Array.from(
@@ -306,7 +318,7 @@ ready(() => {
}
document
.querySelector<HTMLAnchorElement>('a#add-instance-button')
.querySelector('a#add-instance-button')
?.addEventListener('click', (e) => {
const domain = document.querySelector<HTMLInputElement>(
'input[type="text"]#by_domain',
@@ -330,7 +342,7 @@ ready(() => {
}
});
on('submit', 'form', ({ target }) => {
Rails.delegate(document, 'form', 'submit', ({ target }) => {
if (target instanceof HTMLFormElement)
target
.querySelectorAll<HTMLInputElement>('input[type="datetime-local"]')

View File

@@ -4,8 +4,8 @@ import { IntlMessageFormat } from 'intl-messageformat';
import type { MessageDescriptor, PrimitiveType } from 'react-intl';
import { defineMessages } from 'react-intl';
import Rails from '@rails/ujs';
import axios from 'axios';
import { on } from 'delegated-events';
import { throttle } from 'lodash';
import { timeAgoString } from '../mastodon/components/relative_timestamp';
@@ -175,9 +175,10 @@ function loaded() {
});
}
on(
'input',
Rails.delegate(
document,
'input#user_account_attributes_username',
'input',
throttle(
({ target }) => {
if (!(target instanceof HTMLInputElement)) return;
@@ -201,47 +202,60 @@ function loaded() {
),
);
on('input', '#user_password,#user_password_confirmation', () => {
const password = document.querySelector<HTMLInputElement>(
'input#user_password',
);
const confirmation = document.querySelector<HTMLInputElement>(
'input#user_password_confirmation',
);
if (!confirmation || !password) return;
Rails.delegate(
document,
'#user_password,#user_password_confirmation',
'input',
() => {
const password = document.querySelector<HTMLInputElement>(
'input#user_password',
);
const confirmation = document.querySelector<HTMLInputElement>(
'input#user_password_confirmation',
);
if (!confirmation || !password) return;
if (confirmation.value && confirmation.value.length > password.maxLength) {
confirmation.setCustomValidity(
formatMessage(messages.passwordExceedsLength),
);
} else if (password.value && password.value !== confirmation.value) {
confirmation.setCustomValidity(
formatMessage(messages.passwordDoesNotMatch),
);
} else {
confirmation.setCustomValidity('');
}
});
if (
confirmation.value &&
confirmation.value.length > password.maxLength
) {
confirmation.setCustomValidity(
formatMessage(messages.passwordExceedsLength),
);
} else if (password.value && password.value !== confirmation.value) {
confirmation.setCustomValidity(
formatMessage(messages.passwordDoesNotMatch),
);
} else {
confirmation.setCustomValidity('');
}
},
);
}
on('change', '#edit_profile input[type=file]', ({ target }) => {
if (!(target instanceof HTMLInputElement)) return;
Rails.delegate(
document,
'#edit_profile input[type=file]',
'change',
({ target }) => {
if (!(target instanceof HTMLInputElement)) return;
const avatar = document.querySelector<HTMLImageElement>(
`img#${target.id}-preview`,
);
const avatar = document.querySelector<HTMLImageElement>(
`img#${target.id}-preview`,
);
if (!avatar) return;
if (!avatar) return;
let file: File | undefined;
if (target.files) file = target.files[0];
let file: File | undefined;
if (target.files) file = target.files[0];
const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;
const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;
if (url) avatar.src = url;
});
if (url) avatar.src = url;
},
);
on('click', '.input-copy input', ({ target }) => {
Rails.delegate(document, '.input-copy input', 'click', ({ target }) => {
if (!(target instanceof HTMLInputElement)) return;
target.focus();
@@ -249,7 +263,7 @@ on('click', '.input-copy input', ({ target }) => {
target.setSelectionRange(0, target.value.length);
});
on('click', '.input-copy button', ({ target }) => {
Rails.delegate(document, '.input-copy button', 'click', ({ target }) => {
if (!(target instanceof HTMLButtonElement)) return;
const input = target.parentNode?.querySelector<HTMLInputElement>(
@@ -298,22 +312,22 @@ const toggleSidebar = () => {
sidebar.classList.toggle('visible');
};
on('click', '.sidebar__toggle__icon', () => {
Rails.delegate(document, '.sidebar__toggle__icon', 'click', () => {
toggleSidebar();
});
on('keydown', '.sidebar__toggle__icon', (e) => {
Rails.delegate(document, '.sidebar__toggle__icon', 'keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
toggleSidebar();
}
});
on('mouseover', 'img.custom-emoji', ({ target }) => {
Rails.delegate(document, 'img.custom-emoji', 'mouseover', ({ target }) => {
if (target instanceof HTMLImageElement && target.dataset.original)
target.src = target.dataset.original;
});
on('mouseout', 'img.custom-emoji', ({ target }) => {
Rails.delegate(document, 'img.custom-emoji', 'mouseout', ({ target }) => {
if (target instanceof HTMLImageElement && target.dataset.static)
target.src = target.dataset.static;
});
@@ -362,17 +376,22 @@ const setInputHint = (
}
};
on('change', '#account_statuses_cleanup_policy_enabled', ({ target }) => {
if (!(target instanceof HTMLInputElement) || !target.form) return;
Rails.delegate(
document,
'#account_statuses_cleanup_policy_enabled',
'change',
({ target }) => {
if (!(target instanceof HTMLInputElement) || !target.form) return;
target.form
.querySelectorAll<
HTMLInputElement | HTMLSelectElement
>('input:not([type=hidden], #account_statuses_cleanup_policy_enabled), select')
.forEach((input) => {
setInputDisabled(input, !target.checked);
});
});
target.form
.querySelectorAll<
HTMLInputElement | HTMLSelectElement
>('input:not([type=hidden], #account_statuses_cleanup_policy_enabled), select')
.forEach((input) => {
setInputDisabled(input, !target.checked);
});
},
);
const updateDefaultQuotePrivacyFromPrivacy = (
privacySelect: EventTarget | null,
@@ -395,13 +414,18 @@ const updateDefaultQuotePrivacyFromPrivacy = (
}
};
on('change', '#user_settings_attributes_default_privacy', ({ target }) => {
updateDefaultQuotePrivacyFromPrivacy(target);
});
Rails.delegate(
document,
'#user_settings_attributes_default_privacy',
'change',
({ target }) => {
updateDefaultQuotePrivacyFromPrivacy(target);
},
);
// Empty the honeypot fields in JS in case something like an extension
// automatically filled them.
on('submit', '#registration_new_user,#new_user', () => {
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
[
'user_website',
'user_confirm_password',
@@ -415,7 +439,7 @@ on('submit', '#registration_new_user,#new_user', () => {
});
});
on('click', '.rules-list button', ({ target }) => {
Rails.delegate(document, '.rules-list button', 'click', ({ target }) => {
if (!(target instanceof HTMLElement)) {
return;
}

View File

@@ -1,58 +0,0 @@
import { createRoot } from 'react-dom/client';
import { Provider as ReduxProvider } from 'react-redux';
import {
importFetchedAccounts,
importFetchedStatuses,
} from '@/mastodon/actions/importer';
import type { ApiAnnualReportResponse } from '@/mastodon/api/annual_report';
import { Router } from '@/mastodon/components/router';
import { WrapstodonShare } from '@/mastodon/features/annual_report/share';
import { IntlProvider, loadLocale } from '@/mastodon/locales';
import { loadPolyfills } from '@/mastodon/polyfills';
import ready from '@/mastodon/ready';
import { setReport } from '@/mastodon/reducers/slices/annual_report';
import { store } from '@/mastodon/store';
function loaded() {
const mountNode = document.getElementById('wrapstodon');
if (!mountNode) {
throw new Error('Mount node not found');
}
const propsNode = document.getElementById('wrapstodon-data');
if (!propsNode) {
throw new Error('Initial state prop not found');
}
const initialState = JSON.parse(
propsNode.textContent,
) as ApiAnnualReportResponse;
const report = initialState.annual_reports[0];
if (!report) {
throw new Error('Initial state report not found');
}
store.dispatch(importFetchedAccounts(initialState.accounts));
store.dispatch(importFetchedStatuses(initialState.statuses));
store.dispatch(setReport(report));
const root = createRoot(mountNode);
root.render(
<IntlProvider>
<ReduxProvider store={store}>
<Router>
<WrapstodonShare />
</Router>
</ReduxProvider>
</IntlProvider>,
);
}
loadPolyfills()
.then(loadLocale)
.then(() => ready(loaded))
.catch((err: unknown) => {
console.error(err);
});

View File

@@ -0,0 +1,25 @@
export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST';
export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS';
export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL';
export function fetchBundleRequest(skipLoading) {
return {
type: BUNDLE_FETCH_REQUEST,
skipLoading,
};
}
export function fetchBundleSuccess(skipLoading) {
return {
type: BUNDLE_FETCH_SUCCESS,
skipLoading,
};
}
export function fetchBundleFail(error, skipLoading) {
return {
type: BUNDLE_FETCH_FAIL,
error,
skipLoading,
};
}

View File

@@ -709,8 +709,8 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
dispatch(useEmoji(suggestion));
} else if (suggestion.type === 'hashtag') {
completion = token + suggestion.name.slice(token.length - 1);
startPosition = position - 1;
completion = suggestion.name.slice(token.length - 1);
startPosition = position + token.length;
} else if (suggestion.type === 'account') {
completion = `@${getState().getIn(['accounts', suggestion.id, 'acct'])}`;
startPosition = position - 1;

View File

@@ -104,7 +104,7 @@ export function normalizeStatus(status, normalOldStatus, { settings, bogusQuoteP
}
if (normalOldStatus) {
normalStatus.quote_approval ||= normalOldStatus.get('quote_approval');
normalStatus.quote_approval ||= normalOldStatus.quote_approval;
const list = normalOldStatus.get('media_attachments');
if (normalStatus.media_attachments && list) {

View File

@@ -1,5 +1,3 @@
import { checkAnnualReport } from '@/flavours/glitch/reducers/slices/annual_report';
import api from '../api';
import { importFetchedAccount } from './importer';
@@ -31,9 +29,6 @@ export const fetchServer = () => (dispatch, getState) => {
.get('/api/v2/instance').then(({ data }) => {
if (data.contact.account) dispatch(importFetchedAccount(data.contact.account));
dispatch(fetchServerSuccess(data));
if (data.wrapstodon) {
void dispatch(checkAnnualReport());
}
}).catch(err => dispatch(fetchServerFail(err)));
};

View File

@@ -85,8 +85,6 @@ export function fetchStatus(id, {
dispatch(fetchStatusSuccess(skipLoading));
}).catch(error => {
dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
if (error.status === 404)
dispatch(deleteFromTimelines(id));
});
};
}

View File

@@ -1,38 +0,0 @@
import api, { apiRequestGet, getAsyncRefreshHeader } from '../api';
import type { ApiAccountJSON } from '../api_types/accounts';
import type { ApiStatusJSON } from '../api_types/statuses';
import type { AnnualReport } from '../models/annual_report';
export type ApiAnnualReportState =
| 'available'
| 'generating'
| 'eligible'
| 'ineligible';
export const apiGetAnnualReportState = async (year: number) => {
const response = await api().get<{ state: ApiAnnualReportState }>(
`/api/v1/annual_reports/${year}/state`,
);
return {
state: response.data.state,
refresh: getAsyncRefreshHeader(response),
};
};
export const apiRequestGenerateAnnualReport = async (year: number) => {
const response = await api().post(`/api/v1/annual_reports/${year}/generate`);
return {
refresh: getAsyncRefreshHeader(response),
};
};
export interface ApiAnnualReportResponse {
annual_reports: AnnualReport[];
accounts: ApiAccountJSON[];
statuses: ApiStatusJSON[];
}
export const apiGetAnnualReport = (year: number) =>
apiRequestGet<ApiAnnualReportResponse>(`v1/annual_reports/${year}`);

View File

@@ -1,9 +1,8 @@
// See app/serializers/rest/custom_emoji_serializer.rb
// See app/serializers/rest/account_serializer.rb
export interface ApiCustomEmojiJSON {
shortcode: string;
static_url: string;
url: string;
category?: string;
featured?: boolean;
visible_in_picker: boolean;
}

View File

@@ -51,7 +51,7 @@ export interface ApiPreviewCardJSON {
html: string;
width: number;
height: number;
image: string | null;
image: string;
image_description: string;
embed_url: string;
blurhash: string;

View File

@@ -1,5 +1,9 @@
import { setupLinkListeners } from './utils/links';
import Rails from '@rails/ujs';
export function start() {
setupLinkListeners();
try {
Rails.start();
} catch {
// If called twice
}
}

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
return [null, null];
}
word = word.trim();
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left + 1, word];
@@ -159,8 +159,8 @@ export default class AutosuggestInput extends ImmutablePureComponent {
this.input.focus();
};
componentDidUpdate (prevProps) {
if (prevProps.suggestions !== this.props.suggestions && this.props.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
UNSAFE_componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false });
}
}

View File

@@ -29,7 +29,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
return [null, null];
}
word = word.trim();
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left + 1, word];
@@ -50,7 +50,6 @@ const AutosuggestTextarea = forwardRef(({
onKeyUp,
onKeyDown,
onPaste,
onDrop,
onFocus,
autoFocus = true,
lang,
@@ -154,12 +153,6 @@ const AutosuggestTextarea = forwardRef(({
onPaste(e);
}, [onPaste]);
const handleDrop = useCallback((e) => {
if (onDrop) {
onDrop(e);
}
}, [onDrop]);
// Show the suggestions again whenever they change and the textarea is focused
useEffect(() => {
if (suggestions.size > 0 && textareaRef.current === document.activeElement) {
@@ -211,7 +204,6 @@ const AutosuggestTextarea = forwardRef(({
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
onDrop={handleDrop}
dir='auto'
aria-autocomplete='list'
aria-label={placeholder}
@@ -243,7 +235,6 @@ AutosuggestTextarea.propTypes = {
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired,
onDrop: PropTypes.func,
onFocus:PropTypes.func,
autoFocus: PropTypes.bool,
lang: PropTypes.string,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useCallback, useState, useRef } from 'react';
import { useCallback, useState, useEffect, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
@@ -12,15 +12,11 @@ export const ColumnSearchHeader: React.FC<{
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState('');
// 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);
useEffect(() => {
if (!active) {
setValue('');
}
}
}, [active]);
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {

View File

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

View File

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

View File

@@ -216,7 +216,6 @@ export const DropdownMenu = <Item = MenuItem,>({
onClick={handleItemClick}
data-index={i}
aria-disabled={disabled}
type='button'
>
<DropdownMenuItemContent item={option} />
</button>

View File

@@ -108,7 +108,7 @@ export const EditedTimestamp: React.FC<{
onItemClick={handleItemClick}
forceDropdown
>
<button className='dropdown-menu__text-button' type='button'>
<button className='dropdown-menu__text-button'>
<FormattedMessage
id='status.edited'
defaultMessage='Edited {date}'

View File

@@ -1,14 +1,9 @@
import type { FC } from 'react';
import { useContext, useEffect, useState } from 'react';
import classNames from 'classnames';
import { EMOJI_TYPE_CUSTOM } from '@/flavours/glitch/features/emoji/constants';
import { useEmojiAppState } from '@/flavours/glitch/features/emoji/mode';
import {
emojiToInversionClassName,
unicodeHexToUrl,
} from '@/flavours/glitch/features/emoji/normalize';
import { unicodeHexToUrl } from '@/flavours/glitch/features/emoji/normalize';
import {
isStateLoaded,
loadEmojiDataToState,
@@ -46,9 +41,6 @@ export const Emoji: FC<EmojiProps> = ({
}, [appState.currentLocale, state]);
const animate = useContext(AnimateEmojiContext);
const inversionClass = emojiToInversionClassName(code);
const fallback = showFallback ? code : null;
// If the code is invalid or we otherwise know it's not valid, show the fallback.
@@ -87,7 +79,7 @@ export const Emoji: FC<EmojiProps> = ({
src={src}
alt={state.data.unicode}
title={state.data.label}
className={classNames('emojione', inversionClass)}
className='emojione'
loading='lazy'
/>
);

View File

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

View File

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

View File

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

View File

@@ -105,14 +105,12 @@ const hotkeyMatcherMap = {
reply: just('r'),
favourite: just('f'),
boost: just('b'),
bookmark: just('d'),
quote: just('q'),
mention: just('m'),
open: any('enter', 'o'),
openProfile: just('p'),
moveDown: just('j'),
moveUp: just('k'),
moveToTop: just('0'),
toggleHidden: just('x'),
toggleSensitive: just('h'),
toggleComposeSpoilers: optionPlus('x'),
@@ -182,24 +180,25 @@ export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
if (shouldHandleEvent) {
const matchCandidates: {
// A candidate will be have an undefined handler if it's matched,
// but handled in a parent component rather than this one.
handler: ((event: KeyboardEvent) => void) | undefined;
handler: (event: KeyboardEvent) => void;
priority: number;
}[] = [];
(Object.keys(hotkeyMatcherMap) as HotkeyName[]).forEach(
(handlerName) => {
const handler = handlersRef.current[handlerName];
const hotkeyMatcher = hotkeyMatcherMap[handlerName];
const { isMatch, priority } = hotkeyMatcher(
event,
bufferedKeys.current,
);
if (handler) {
const hotkeyMatcher = hotkeyMatcherMap[handlerName];
if (isMatch) {
matchCandidates.push({ handler, priority });
const { isMatch, priority } = hotkeyMatcher(
event,
bufferedKeys.current,
);
if (isMatch) {
matchCandidates.push({ handler, priority });
}
}
},
);

View File

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

View File

@@ -1,9 +1,7 @@
import { useCallback, forwardRef } from 'react';
import { useState, useEffect, useCallback, forwardRef } from 'react';
import classNames from 'classnames';
import { usePrevious } from '../hooks/usePrevious';
import { AnimatedNumber } from './animated_number';
import type { IconProp } from './icon';
import { Icon } from './icon';
@@ -61,6 +59,23 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
},
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(
(e) => {
e.preventDefault();
@@ -97,15 +112,12 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
...(active ? activeStyle : {}),
};
const previousActive = usePrevious(active) ?? active;
const shouldAnimate = animate && active !== previousActive;
const classes = classNames(className, 'icon-button', {
active,
disabled,
inverted,
activate: shouldAnimate && active,
deactivate: shouldAnimate && !active,
activate,
deactivate,
overlayed: overlay,
'icon-button--with-counter': typeof counter !== 'undefined',
});

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
import classNames from 'classnames';
import logo from '@/images/logo.svg';
export const WordmarkLogo: React.FC = () => (
@@ -9,12 +7,8 @@ export const WordmarkLogo: React.FC = () => (
</svg>
);
export const IconLogo: React.FC<{ className?: string }> = ({ className }) => (
<svg
viewBox='0 0 79 79'
className={classNames('logo logo--icon', className)}
role='img'
>
export const IconLogo: React.FC = () => (
<svg viewBox='0 0 79 79' className='logo logo--icon' role='img'>
<title>Mastodon</title>
<use xlinkHref='#logo-symbol-icon' />
</svg>

View File

@@ -251,13 +251,15 @@ class MediaGallery extends PureComponent {
window.removeEventListener('resize', this.handleResize);
}
componentDidUpdate (prevProps) {
if (!is(prevProps.media, this.props.media) && this.props.visible === undefined) {
this.setState({ visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all' });
} else if (!is(prevProps.visible, this.props.visible) && this.props.visible !== undefined) {
this.setState({ visible: this.props.visible });
UNSAFE_componentWillReceiveProps (nextProps) {
if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
} else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
this.setState({ visible: nextProps.visible });
}
}
componentDidUpdate () {
if (this.node) {
this.handleResize();
}

View File

@@ -66,6 +66,14 @@ class ModalRoot extends PureComponent {
}
}
UNSAFE_componentWillReceiveProps (nextProps) {
if (!!nextProps.children && !this.props.children) {
this.activeElement = document.activeElement;
this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
}
}
componentDidUpdate (prevProps) {
if (!this.props.children && !!prevProps.children) {
this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
@@ -82,15 +90,9 @@ class ModalRoot extends PureComponent {
this._handleModalClose();
}
if (this.props.children && !prevProps.children) {
this.activeElement = document.activeElement;
this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
this._handleModalOpen();
}
if (this.props.children) {
this._ensureHistoryBuffer();
}

View File

@@ -35,9 +35,6 @@ const messages = defineMessages({
},
});
const isPollExpired = (expiresAt: Model.Poll['expires_at']) =>
new Date(expiresAt).getTime() < Date.now();
interface PollProps {
pollId: string;
status: Status;
@@ -61,7 +58,8 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
if (!poll) {
return false;
}
return poll.expired || isPollExpired(poll.expires_at);
const expiresAt = poll.expires_at;
return poll.expired || new Date(expiresAt).getTime() < Date.now();
}, [poll]);
const timeRemaining = useMemo(() => {
if (!poll) {
@@ -173,14 +171,13 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
className='button button-secondary'
disabled={voteDisabled}
onClick={handleVote}
type='button'
>
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
</button>
)}
{!showResults && (
<>
<button className='poll__link' onClick={handleReveal} type='button'>
<button className='poll__link' onClick={handleReveal}>
<FormattedMessage id='poll.reveal' defaultMessage='See results' />
</button>{' '}
·{' '}
@@ -188,11 +185,7 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
)}
{showResults && !disabled && (
<>
<button
className='poll__link'
onClick={handleRefresh}
type='button'
>
<button className='poll__link' onClick={handleRefresh}>
<FormattedMessage id='poll.refresh' defaultMessage='Refresh' />
</button>{' '}
·{' '}

View File

@@ -634,7 +634,7 @@ class Status extends ImmutablePureComponent {
} else if (status.get('card') && settings.get('inline_preview_cards') && !this.props.muted && !status.get('quote')) {
media.push(
<Card
key={`${status.get('id')}-${status.get('edited_at')}`}
onOpenMedia={this.handleOpenMedia}
card={status.get('card')}
sensitive={status.get('sensitive')}
/>,

View File

@@ -224,12 +224,7 @@ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({ index, item, onClick }) => {
})}
key={`${text}-${index}`}
>
<button
onClick={onClick}
aria-disabled={disabled}
data-index={index}
type='button'
>
<button onClick={onClick} aria-disabled={disabled} data-index={index}>
<DropdownMenuItemContent item={item} />
</button>
</li>

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