mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-13 15:58:50 +00:00
Compare commits
322 Commits
v4.4.0-bet
...
v4.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a51ad7ebd | ||
|
|
06a46e77b8 | ||
|
|
69e14246b8 | ||
|
|
174370dec2 | ||
|
|
18e08bf493 | ||
|
|
c1794fb948 | ||
|
|
061c966ab3 | ||
|
|
326f6bc12a | ||
|
|
bd442485d0 | ||
|
|
333a17a478 | ||
|
|
388e09e1a3 | ||
|
|
2dcededcf0 | ||
|
|
2db8a328cd | ||
|
|
b4a950c2fc | ||
|
|
194645aada | ||
|
|
48aaecec7b | ||
|
|
6cac651ff2 | ||
|
|
385dd5ea37 | ||
|
|
0c5ce23ae4 | ||
|
|
cb937a920e | ||
|
|
7051458467 | ||
|
|
025abf7325 | ||
|
|
28373a9c88 | ||
|
|
42884d8727 | ||
|
|
000ff9c05f | ||
|
|
921af5d27d | ||
|
|
878e1e65eb | ||
|
|
06f5f270cc | ||
|
|
961c22a6fd | ||
|
|
07b4fa55c8 | ||
|
|
041bce9ed6 | ||
|
|
d7a08d81b6 | ||
|
|
8bb81f9496 | ||
|
|
605df74f06 | ||
|
|
98e90b7c1f | ||
|
|
a203a05eb1 | ||
|
|
68090cd8be | ||
|
|
dd064aaa36 | ||
|
|
e6e8974785 | ||
|
|
5e95c63b5b | ||
|
|
498af63b85 | ||
|
|
0bb2dc9d26 | ||
|
|
2eec4da8fc | ||
|
|
c357a7f8d6 | ||
|
|
bae258925c | ||
|
|
e8a603b18f | ||
|
|
f00c8e3245 | ||
|
|
153af19f55 | ||
|
|
964916c71b | ||
|
|
8782e860b6 | ||
|
|
641c0c6393 | ||
|
|
0383100b0e | ||
|
|
87db28cebc | ||
|
|
ac4b735c67 | ||
|
|
bc3dacc371 | ||
|
|
6d017dbf10 | ||
|
|
f21c92bb45 | ||
|
|
7b98298f85 | ||
|
|
1f8378c12d | ||
|
|
f7b4580b49 | ||
|
|
0d650780e2 | ||
|
|
1804a87193 | ||
|
|
9576434d47 | ||
|
|
b804ed0cba | ||
|
|
48451b782d | ||
|
|
2e0a00ab46 | ||
|
|
e4618a6ba5 | ||
|
|
a9f2ec45da | ||
|
|
c1ef1f62d5 | ||
|
|
d285b07774 | ||
|
|
0156ed6641 | ||
|
|
9e5b9433f8 | ||
|
|
34b8ff8267 | ||
|
|
c9a1e27a49 | ||
|
|
dbb20f76a7 | ||
|
|
91741214e1 | ||
|
|
8fa32ca8ba | ||
|
|
8285194451 | ||
|
|
392eaf1010 | ||
|
|
fa9318083e | ||
|
|
c6dddbb66e | ||
|
|
c52848b444 | ||
|
|
0a7418e6d8 | ||
|
|
72f2f35bfb | ||
|
|
0f9f27972d | ||
|
|
9f16f41678 | ||
|
|
47fda2df2c | ||
|
|
377289c961 | ||
|
|
f852da50f6 | ||
|
|
e44143db8c | ||
|
|
73f77edf40 | ||
|
|
eb1674ec50 | ||
|
|
c9f17899a6 | ||
|
|
97d3dac4b6 | ||
|
|
e44b333660 | ||
|
|
26ee915d0b | ||
|
|
93bdb16817 | ||
|
|
f723718576 | ||
|
|
f7c36f44a4 | ||
|
|
c6a99eaf5b | ||
|
|
d8b0beb70d | ||
|
|
eb3823f0cf | ||
|
|
7fff0d24c8 | ||
|
|
9fccf0a8c6 | ||
|
|
8ba1487f30 | ||
|
|
644da36336 | ||
|
|
fb5b8ae0a5 | ||
|
|
fd902c04f7 | ||
|
|
8ee8231a43 | ||
|
|
c4128d89c9 | ||
|
|
9954acf61d | ||
|
|
0276354775 | ||
|
|
dba636da7a | ||
|
|
43e9186e5d | ||
|
|
0338733531 | ||
|
|
1be48d0cab | ||
|
|
e60014ed9c | ||
|
|
0d7f1584bc | ||
|
|
36f01af6c4 | ||
|
|
16057f550d | ||
|
|
e79ecabd0a | ||
|
|
c023ebc87a | ||
|
|
ebc6897afb | ||
|
|
b08ccaa5b3 | ||
|
|
b9b1500fc5 | ||
|
|
d28a4428b5 | ||
|
|
6166e61638 | ||
|
|
e5aa8c1ff3 | ||
|
|
8837fd8c54 | ||
|
|
94c644983e | ||
|
|
13a07e44f1 | ||
|
|
f41981e772 | ||
|
|
9cb5a77c3e | ||
|
|
ac039d5f13 | ||
|
|
adf812efb3 | ||
|
|
3f743b1a07 | ||
|
|
2b360c479c | ||
|
|
204ff46f7e | ||
|
|
54f9a1b43b | ||
|
|
e9b1c1edfe | ||
|
|
455df074fe | ||
|
|
68fb65b08d | ||
|
|
c9d3b8e3a5 | ||
|
|
f8f458e5e6 | ||
|
|
0ec6c26af3 | ||
|
|
08597a1819 | ||
|
|
102a7635d6 | ||
|
|
b1fe35d7d2 | ||
|
|
adf01b021c | ||
|
|
aac51707d1 | ||
|
|
aa345c4630 | ||
|
|
70c6e09e0f | ||
|
|
1a7fd2f446 | ||
|
|
33402722f3 | ||
|
|
cc6a16e62c | ||
|
|
474464ffff | ||
|
|
f4c850dff6 | ||
|
|
5baeb567c0 | ||
|
|
a45d2b4398 | ||
|
|
457d392837 | ||
|
|
fc87ce7ca1 | ||
|
|
98f98249ff | ||
|
|
af157939d9 | ||
|
|
9d07a31380 | ||
|
|
1cb026f962 | ||
|
|
59dc0bd6f3 | ||
|
|
d2d5767f32 | ||
|
|
56e092927d | ||
|
|
3731738aa0 | ||
|
|
cbdd4b68c7 | ||
|
|
d17e4d11d0 | ||
|
|
0bb83dfe7d | ||
|
|
cc618f0738 | ||
|
|
0237f79665 | ||
|
|
bde8aa2781 | ||
|
|
19aa68897b | ||
|
|
2c751a6a19 | ||
|
|
77862311cf | ||
|
|
69f298731e | ||
|
|
af6ee7f230 | ||
|
|
7c4393e719 | ||
|
|
013c527406 | ||
|
|
c644413f8a | ||
|
|
ca3cc36549 | ||
|
|
b2506478ba | ||
|
|
ad32834ccd | ||
|
|
4d29215ad3 | ||
|
|
0544898b8b | ||
|
|
aef1b4f5b7 | ||
|
|
070455cad0 | ||
|
|
6f2aba989f | ||
|
|
fe337904b2 | ||
|
|
a27202fbbc | ||
|
|
ed4788a342 | ||
|
|
500fb00c2f | ||
|
|
e20ef39da4 | ||
|
|
7ccf7fc456 | ||
|
|
9e5e3a50a0 | ||
|
|
7da6b07c75 | ||
|
|
71d4ce1c22 | ||
|
|
e8868af079 | ||
|
|
e28f86cbe5 | ||
|
|
83d5016ca3 | ||
|
|
672d411c2c | ||
|
|
5ce055759f | ||
|
|
39a1a4a8d7 | ||
|
|
ab7f50ce4e | ||
|
|
80af1ea434 | ||
|
|
7e85247645 | ||
|
|
f92ff6d699 | ||
|
|
3fef107791 | ||
|
|
d5ba3aa6bd | ||
|
|
442401c47e | ||
|
|
b977600e07 | ||
|
|
00c34c6179 | ||
|
|
c4feceab06 | ||
|
|
5259623523 | ||
|
|
220b78d4a9 | ||
|
|
c08d53ec76 | ||
|
|
319fbbbfac | ||
|
|
825312d4b0 | ||
|
|
2254f47702 | ||
|
|
9f94ddcd40 | ||
|
|
ccf7760205 | ||
|
|
24d943fee0 | ||
|
|
d1fb957361 | ||
|
|
0ea4267839 | ||
|
|
6fcbd7c17a | ||
|
|
c3ca5d49cf | ||
|
|
3166396a16 | ||
|
|
180754a14e | ||
|
|
1a732157d4 | ||
|
|
0629ea4cba | ||
|
|
e9c150393f | ||
|
|
9101067154 | ||
|
|
1200f70ae7 | ||
|
|
3509064801 | ||
|
|
d4d77ace97 | ||
|
|
fa33eff372 | ||
|
|
86a7a1ef3b | ||
|
|
14c5e3433c | ||
|
|
1190780d79 | ||
|
|
533af5dcee | ||
|
|
3c7da05f85 | ||
|
|
7c1178ab84 | ||
|
|
fb532ab172 | ||
|
|
aa6d1d4ac1 | ||
|
|
3aed93711c | ||
|
|
f53bb4cd7d | ||
|
|
9896bed85f | ||
|
|
2c828748a3 | ||
|
|
1623d54ec0 | ||
|
|
722fb1ff55 | ||
|
|
933ee420c3 | ||
|
|
0cdf11d6ad | ||
|
|
a13b33d851 | ||
|
|
8cf246e4d3 | ||
|
|
629bb74451 | ||
|
|
b8cc9b3290 | ||
|
|
2c085ea044 | ||
|
|
bb89a64af0 | ||
|
|
c543e823ab | ||
|
|
7a7e0ba4cd | ||
|
|
c727701839 | ||
|
|
1824b1fd29 | ||
|
|
1bf8a642f0 | ||
|
|
dc2cfd50a0 | ||
|
|
a2a6117143 | ||
|
|
24803db2bc | ||
|
|
90183b6c27 | ||
|
|
c3022fe10f | ||
|
|
f3d60a4a6f | ||
|
|
1dd8a99d9f | ||
|
|
66a42c11ba | ||
|
|
132f32dd70 | ||
|
|
e557769a3c | ||
|
|
87e72fb8e1 | ||
|
|
49071fd4c0 | ||
|
|
9df67e0c74 | ||
|
|
3b13487e8f | ||
|
|
564d74ea36 | ||
|
|
d887790e86 | ||
|
|
d0c6f30378 | ||
|
|
ba75ba3adc | ||
|
|
c92e21813e | ||
|
|
076005eae2 | ||
|
|
619b9bc2d8 | ||
|
|
ac68d7e471 | ||
|
|
00154f3f92 | ||
|
|
4b22aa93a2 | ||
|
|
b10fde673d | ||
|
|
68810643d8 | ||
|
|
f2cfa4f482 | ||
|
|
989ca63b59 | ||
|
|
f2cdbefa3c | ||
|
|
86627624f1 | ||
|
|
c09f9a93f1 | ||
|
|
963f4977d6 | ||
|
|
5fde019e39 | ||
|
|
ad8f984cef | ||
|
|
af314a833d | ||
|
|
9a649a2072 | ||
|
|
802ade62ce | ||
|
|
4dbbe520fb | ||
|
|
6bb29dec13 | ||
|
|
a6a35ad1ce | ||
|
|
2085b0b53a | ||
|
|
520974e052 | ||
|
|
3d474807bf | ||
|
|
09208eafa4 | ||
|
|
25c4574480 | ||
|
|
e2c5a2abaa | ||
|
|
a80f77a996 | ||
|
|
1297ad759e | ||
|
|
1fdcaaebbb | ||
|
|
375add0c83 | ||
|
|
a4bc438010 | ||
|
|
1d152d2181 | ||
|
|
250c3b0c1f | ||
|
|
1dafd8c9dd | ||
|
|
6637ecb460 | ||
|
|
e9f197740d |
1
.github/.well-known/funding-manifest-urls
vendored
Normal file
1
.github/.well-known/funding-manifest-urls
vendored
Normal file
@@ -0,0 +1 @@
|
||||
https://joinmastodon.org/funding.json
|
||||
8
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
8
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
@@ -47,8 +47,8 @@ body:
|
||||
attributes:
|
||||
label: Mastodon version
|
||||
description: |
|
||||
This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1`
|
||||
placeholder: v4.3.0
|
||||
This is displayed at the bottom of the About page, eg. `v4.4.0-beta.1`
|
||||
placeholder: v4.4.0-beta.1
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -56,7 +56,7 @@ body:
|
||||
label: Browser name and version
|
||||
description: |
|
||||
What browser are you using when getting this bug? Please specify the version as well.
|
||||
placeholder: Firefox 131.0.0
|
||||
placeholder: Firefox 139.0.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -64,7 +64,7 @@ body:
|
||||
label: Operating system
|
||||
description: |
|
||||
What OS are you running? Please specify the version as well.
|
||||
placeholder: macOS 15.0.1
|
||||
placeholder: macOS 15.5
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -48,8 +48,8 @@ body:
|
||||
attributes:
|
||||
label: Mastodon version
|
||||
description: |
|
||||
This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1`
|
||||
placeholder: v4.3.0
|
||||
This is displayed at the bottom of the About page, eg. `v4.4.0-beta.1`
|
||||
placeholder: v4.4.0-beta.1
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
@@ -59,7 +59,7 @@ body:
|
||||
Any additional technical details you may have, like logs or error traces
|
||||
value: |
|
||||
If this is happening on your own Mastodon server, please fill out those:
|
||||
- Ruby version: (from `ruby --version`, eg. v3.4.1)
|
||||
- Node.js version: (from `node --version`, eg. v20.18.0)
|
||||
- Ruby version: (from `ruby --version`, eg. v3.4.4)
|
||||
- Node.js version: (from `node --version`, eg. v22.16.0)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/3.troubleshooting.yml
vendored
8
.github/ISSUE_TEMPLATE/3.troubleshooting.yml
vendored
@@ -49,7 +49,7 @@ body:
|
||||
label: Mastodon version
|
||||
description: |
|
||||
This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1`
|
||||
placeholder: v4.3.0
|
||||
placeholder: v4.4.0-beta.1
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
@@ -59,9 +59,9 @@ body:
|
||||
Details about your environment, like how Mastodon is deployed, if containers are used, version numbers, etc.
|
||||
value: |
|
||||
Please at least include those informations:
|
||||
- Operating system: (eg. Ubuntu 22.04)
|
||||
- Ruby version: (from `ruby --version`, eg. v3.4.1)
|
||||
- Node.js version: (from `node --version`, eg. v20.18.0)
|
||||
- Operating system: (eg. Ubuntu 24.04.2)
|
||||
- Ruby version: (from `ruby --version`, eg. v3.4.4)
|
||||
- Node.js version: (from `node --version`, eg. v22.16.0)
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
4
.github/workflows/build-releases.yml
vendored
4
.github/workflows/build-releases.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.4.') }}
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.4.') }}
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
|
||||
41
.github/workflows/chromatic.yml
vendored
Normal file
41
.github/workflows/chromatic.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: 'Chromatic'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- renovate/*
|
||||
- stable-*
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- '**/*.js'
|
||||
- '**/*.jsx'
|
||||
- '**/*.ts'
|
||||
- '**/*.tsx'
|
||||
- '**/*.css'
|
||||
- '**/*.scss'
|
||||
- '.github/workflows/chromatic.yml'
|
||||
|
||||
jobs:
|
||||
chromatic:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'mastodon/mastodon'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Javascript environment
|
||||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
- name: Build Storybook
|
||||
run: yarn build-storybook
|
||||
|
||||
- name: Run Chromatic
|
||||
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'
|
||||
1
.github/workflows/crowdin-upload.yml
vendored
1
.github/workflows/crowdin-upload.yml
vendored
@@ -14,6 +14,7 @@ on:
|
||||
- config/locales-glitch/devise.en.yml
|
||||
- config/locales-glitch/doorkeeper.en.yml
|
||||
- .github/workflows/crowdin-upload.yml
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
upload-translations:
|
||||
|
||||
15
.github/workflows/test-ruby.yml
vendored
15
.github/workflows/test-ruby.yml
vendored
@@ -332,6 +332,21 @@ jobs:
|
||||
- name: Load database schema
|
||||
run: './bin/rails db:create db:schema:load db:seed'
|
||||
|
||||
- name: Cache Playwright Chromium browser
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
|
||||
|
||||
- name: Install Playwright Chromium browser (with deps)
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: yarn run playwright install --with-deps chromium
|
||||
|
||||
- name: Install Playwright Chromium browser deps
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: yarn run playwright install-deps chromium
|
||||
|
||||
- run: bin/rspec spec/system --tag streaming --tag js
|
||||
|
||||
- name: Archive logs
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -75,3 +75,6 @@ docker-compose.override.yml
|
||||
|
||||
# Ignore local-only rspec configuration
|
||||
.rspec-local
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
@@ -82,6 +82,9 @@ AUTHORS.md
|
||||
# Process a few selected JS files
|
||||
!lint-staged.config.js
|
||||
|
||||
# Ignore config YAML files that include ERB/ruby code prettier does not understand
|
||||
/config/email.yml
|
||||
|
||||
# Ignore glitch-soc emoji map file
|
||||
/app/javascript/flavours/glitch/features/emoji/emoji_map.json
|
||||
/app/javascript/flavours/glitch/features/emoji/emoji_data.json
|
||||
@@ -94,4 +97,4 @@ AUTHORS.md
|
||||
app/javascript/flavours/glitch/styles/reset.scss
|
||||
|
||||
# Ignore win95 theme
|
||||
app/javascript/styles/win95.scss
|
||||
app/javascript/styles/win95.scss
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
Naming/BlockForwarding:
|
||||
EnforcedStyle: explicit
|
||||
|
||||
Naming/PredicateMethod:
|
||||
Enabled: false
|
||||
|
||||
@@ -23,5 +23,6 @@ RSpec/SpecFilePathFormat:
|
||||
ActivityPub: activitypub
|
||||
DeepL: deepl
|
||||
FetchOEmbedService: fetch_oembed_service
|
||||
OAuth: oauth
|
||||
OEmbedController: oembed_controller
|
||||
OStatus: ostatus
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# This configuration was generated by
|
||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||
# using RuboCop version 1.75.8.
|
||||
# using RuboCop version 1.77.0.
|
||||
# The point is for the user to remove these configuration records
|
||||
# one by one as the offenses are removed from the code base.
|
||||
# Note that changes in the inspected code, or installation of new
|
||||
@@ -28,26 +28,12 @@ Metrics/PerceivedComplexity:
|
||||
Max: 27
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
# Configuration parameters: AllowedVars.
|
||||
# Configuration parameters: AllowedVars, DefaultToNil.
|
||||
Style/FetchEnvVar:
|
||||
Exclude:
|
||||
- 'config/initializers/2_limited_federation_mode.rb'
|
||||
- 'config/initializers/paperclip.rb'
|
||||
- 'lib/tasks/repo.rake'
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals.
|
||||
Style/GuardClause:
|
||||
Enabled: false
|
||||
|
||||
# Configuration parameters: AllowedMethods.
|
||||
# AllowedMethods: respond_to_missing?
|
||||
Style/OptionalBooleanParameter:
|
||||
Exclude:
|
||||
- 'app/lib/admin/system_check/message.rb'
|
||||
- 'app/lib/request.rb'
|
||||
- 'app/lib/webfinger.rb'
|
||||
- 'app/services/block_domain_service.rb'
|
||||
- 'app/services/fetch_resource_service.rb'
|
||||
- 'app/workers/domain_block_worker.rb'
|
||||
- 'app/workers/unfollow_follow_worker.rb'
|
||||
|
||||
31
.storybook/main.ts
Normal file
31
.storybook/main.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../app/javascript/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-docs',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/addon-vitest',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
staticDirs: [
|
||||
'./static',
|
||||
// We need to manually specify the assets because of the symlink in public/sw.js
|
||||
...[
|
||||
'avatars',
|
||||
'emoji',
|
||||
'headers',
|
||||
'sounds',
|
||||
'badge.png',
|
||||
'loading.gif',
|
||||
'loading.png',
|
||||
'oops.gif',
|
||||
'oops.png',
|
||||
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
7
.storybook/manager.ts
Normal file
7
.storybook/manager.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { addons } from 'storybook/manager-api';
|
||||
|
||||
import theme from './storybook-theme';
|
||||
|
||||
addons.setConfig({
|
||||
theme,
|
||||
});
|
||||
18
.storybook/preview-head.html
Normal file
18
.storybook/preview-head.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<style>
|
||||
/* Increase docs font size */
|
||||
.sbdocs.sbdocs-content :where(p:not(.sb-anchor, .sb-unstyled, .sb-unstyled p)),
|
||||
.sbdocs.sbdocs-content :where(li:not(.sb-anchor, .sb-unstyled, .sb-unstyled li)) {
|
||||
font-size: 1.0666rem; /* 17px */
|
||||
line-height: 1.585; /* 27px */
|
||||
}
|
||||
|
||||
.sbdocs.sbdocs-content :where(p:not(.sb-anchor, .sb-unstyled, .sb-unstyled p)) code,
|
||||
.sbdocs.sbdocs-content :where(li:not(.sb-anchor, .sb-unstyled, .sb-unstyled li)) code {
|
||||
font-size: 0.875rem; /* ~15px */
|
||||
}
|
||||
|
||||
/* Bring numbers back for ordered lists */
|
||||
ol {
|
||||
list-style: revert !important;
|
||||
}
|
||||
</style>
|
||||
146
.storybook/preview.tsx
Normal file
146
.storybook/preview.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
import { initialize, mswLoader } from 'msw-storybook-addon';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import type { LocaleData } from '@/mastodon/locales';
|
||||
import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers';
|
||||
import { defaultMiddleware } from '@/mastodon/store/store';
|
||||
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
|
||||
|
||||
// If you want to run the dark theme during development,
|
||||
// you can change the below to `/application.scss`
|
||||
import '../app/javascript/styles/mastodon-light.scss';
|
||||
|
||||
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
||||
query: { as: 'json' },
|
||||
});
|
||||
|
||||
// Initialize MSW
|
||||
initialize({
|
||||
onUnhandledRequest: unhandledRequestHandler,
|
||||
});
|
||||
|
||||
const preview: Preview = {
|
||||
// Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
globalTypes: {
|
||||
locale: {
|
||||
description: 'Locale for the story',
|
||||
toolbar: {
|
||||
title: 'Locale',
|
||||
icon: 'globe',
|
||||
items: Object.keys(localeFiles).map((path) =>
|
||||
path.replace('/mastodon/locales/', '').replace('.json', ''),
|
||||
),
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: {
|
||||
locale: 'en',
|
||||
},
|
||||
decorators: [
|
||||
(Story, { parameters }) => {
|
||||
const { state = {} } = parameters;
|
||||
let reducer = rootReducer;
|
||||
if (typeof state === 'object' && state) {
|
||||
reducer = reducerWithInitialState(state as Record<string, unknown>);
|
||||
}
|
||||
const store = configureStore({
|
||||
reducer,
|
||||
middleware(getDefaultMiddleware) {
|
||||
return getDefaultMiddleware(defaultMiddleware);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Story />
|
||||
</Provider>
|
||||
);
|
||||
},
|
||||
(Story, { globals }) => {
|
||||
const currentLocale = (globals.locale as string) || 'en';
|
||||
const [messages, setMessages] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({});
|
||||
const currentLocaleData = messages[currentLocale];
|
||||
|
||||
useEffect(() => {
|
||||
async function loadLocaleData() {
|
||||
const { default: localeFile } = (await import(
|
||||
`@/mastodon/locales/${currentLocale}.json`
|
||||
)) as { default: LocaleData['messages'] };
|
||||
setMessages((prevLocales) => ({
|
||||
...prevLocales,
|
||||
[currentLocale]: localeFile,
|
||||
}));
|
||||
}
|
||||
if (!currentLocaleData) {
|
||||
void loadLocaleData();
|
||||
}
|
||||
}, [currentLocale, currentLocaleData]);
|
||||
|
||||
return (
|
||||
<IntlProvider
|
||||
locale={currentLocale}
|
||||
messages={currentLocaleData}
|
||||
textComponent='span'
|
||||
>
|
||||
<Story />
|
||||
</IntlProvider>
|
||||
);
|
||||
},
|
||||
(Story) => (
|
||||
<MemoryRouter>
|
||||
<Story />
|
||||
<Route
|
||||
path='*'
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
render={({ location }) => {
|
||||
if (location.pathname !== '/') {
|
||||
action(`route change to ${location.pathname}`)(location);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
loaders: [mswLoader],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: 'todo',
|
||||
},
|
||||
|
||||
state: {},
|
||||
|
||||
docs: {},
|
||||
|
||||
msw: {
|
||||
handlers: mockHandlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
344
.storybook/static/mockServiceWorker.js
Normal file
344
.storybook/static/mockServiceWorker.js
Normal file
@@ -0,0 +1,344 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.10.2'
|
||||
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
*/
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(event, client, requestId)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
7
.storybook/storybook-addon-vitest.d.ts
vendored
Normal file
7
.storybook/storybook-addon-vitest.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// The addon package.json incorrectly exports types, so we need to override them here.
|
||||
// See: https://github.com/storybookjs/storybook/blob/v9.0.4/code/addons/vitest/package.json#L70-L76
|
||||
declare module '@storybook/addon-vitest/vitest-plugin' {
|
||||
export * from '@storybook/addon-vitest/dist/vitest-plugin/index';
|
||||
}
|
||||
|
||||
export {};
|
||||
7
.storybook/storybook-theme.ts
Normal file
7
.storybook/storybook-theme.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { create } from 'storybook/theming';
|
||||
|
||||
export default create({
|
||||
base: 'light',
|
||||
brandTitle: 'Mastodon Storybook',
|
||||
brandImage: 'https://joinmastodon.org/logos/wordmark-black-text.svg',
|
||||
});
|
||||
8
.storybook/vitest.setup.ts
Normal file
8
.storybook/vitest.setup.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
|
||||
import { setProjectAnnotations } from '@storybook/react-vite';
|
||||
|
||||
import * as projectAnnotations from './preview';
|
||||
|
||||
// This is an important step to apply the right configuration when testing your stories.
|
||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
||||
65
CHANGELOG.md
65
CHANGELOG.md
@@ -2,12 +2,12 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.4.0] - UNRELEASED
|
||||
## [4.4.0] - 2025-07-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Add “Followers you know” widget to user profiles and hover cards** (#34652, #34678, #34681, #34697, #34699, #34769, #34774 and #34914 by @diondiondion)
|
||||
- **Add featured tab to profiles on web UI and rework pinned posts** (#34405, #34483, #34491, #34754, #34855, #34858, #34868, and #34869 by @ChaosExAnima, @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
- **Add featured tab to profiles on web UI and rework pinned posts** (#34405, #34483, #34491, #34754, #34855, #34858, #34868, #34869, #34927, #34995, #35056 and #34931 by @ChaosExAnima, @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
- Add endorsed accounts to featured tab in web UI (#34421 and #34568 by @Gargron)\
|
||||
This also includes the following new REST API endpoints:
|
||||
- `GET /api/v1/accounts/:id/endorsements`: https://docs.joinmastodon.org/methods/accounts/#endorsements
|
||||
@@ -19,14 +19,14 @@ All notable changes to this project will be documented in this file.
|
||||
- `POST /api/v1/tags/:id/unfeature`: https://docs.joinmastodon.org/methods/tags/#unfeature
|
||||
- Add reminder when about to post without alt text in web UI (#33760 and #33784 by @Gargron)
|
||||
- Add a warning in Web UI when composing a post when the selected and detected language are different (#33042, #33683, #33700, #33724, #33770, and #34193 by @ClearlyClaire and @Gargron)
|
||||
- Add ability to reorder and translate server rules (#34637, #34737, #34494, #34756, and #34820 by @ChaosExAnima and @ClearlyClaire)\
|
||||
- Add support for verifying and displaying remote quote posts (#34370, #34481, #34510, #34551, #34480, #34479, #34553, #34584, #34623, #34738, #34766, #34770, #34772, #34773, #34786, #34790, #34864, #34957, #34961, #35016, #35022, #35036, #34946, #34945 and #34958 by @ClearlyClaire and @diondiondion)\
|
||||
Support for verifying remote quotes according to [FEP-044f](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md) and displaying them in the Web UI has been implemented.\
|
||||
Quoting other people is not implemented yet, and it is currently not possible to mark your own posts as allowing quotes. However, a new “Who can quote” setting has been added to the “Posting defaults” section of the user settings. This setting allows you to set a default that will be used for new posts made on Mastodon 4.5 and newer, when quote posts will be fully implemented.\
|
||||
In the REST API, quote posts are represented by a new `quote` attribute on `Status` and `StatusEdit` entities: https://docs.joinmastodon.org/entities/StatusEdit/#quote https://docs.joinmastodon.org/entities/Status/#quote
|
||||
- Add ability to reorder and translate server rules (#34637, #34737, #34494, #34756, #34820, #34997, #35170, #35174 and #35174 by @ChaosExAnima and @ClearlyClaire)\
|
||||
Rules are now shown in the user’s language, if a translation has been set.\
|
||||
In the REST API, `Rule` entities now have a new `translations` attribute: https://docs.joinmastodon.org/entities/Rule/#translations
|
||||
- Add emoji from Twemoji 15.1.0, including in the emoji picker/completion (#33395, #34321, #34620, and #34677 by @ChaosExAnima, @ClearlyClaire, @TheEssem, and @eramdam)
|
||||
- Add experimental support for verifying and displaying remote quote posts (#34370, #34481, #34510, #34551, #34480, #34479, #34553, #34584, #34623, #34738, #34766, #34770, #34772, #34773, #34786, #34790, and #34864 by @ClearlyClaire and @diondiondion)\
|
||||
Support for verifying remote quotes according to [FEP-044f](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md) and displaying them in the Web UI has been implemented. Such quotes are currently only processed if the `inbound_quotes` experimental feature is enabled (`EXPERIMENTAL_FEATURES=inbound_quotes`).\
|
||||
Quoting other people is not implemented yet, and it is currently not possible to mark your own posts as allowing quotes. However, a new “Who can quote” setting has been added to the “Posting defaults” section of the user settings. This setting allows you to set a default that will be used for new posts made on Mastodon 4.5 and newer, when quote posts will be fully implemented.\
|
||||
In the REST API, quote posts are represented by a new `quote` attribute on `Status` and `StatusEdit` entities: https://docs.joinmastodon.org/entities/StatusEdit/#quote https://docs.joinmastodon.org/entities/Status/#quote
|
||||
- Add option to remove account from followers in web UI (#34488 by @Gargron)
|
||||
- Add relationship tags to profiles and hover cards in web UI (#34467 and #34792 by @Gargron and @diondiondion)
|
||||
- Add ability to open posts in a new tab by middle-clicking in web UI (#32988, #33106, #33419, and #34700 by @ClearlyClaire, @Gargron, and @tribela)
|
||||
@@ -38,8 +38,11 @@ All notable changes to this project will be documented in this file.
|
||||
Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path.
|
||||
- Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron)
|
||||
- Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm)
|
||||
- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, and #34527 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
|
||||
Server administrators can now fill in Terms of Service, optionally using a provided template.
|
||||
- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126, #35127 and #35233 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
|
||||
Server administrators can now fill in Terms of Service and notify their users of upcoming changes.
|
||||
- Add optional bulk mailer settings (#35191 and #35203 by @oneiros)\
|
||||
This adds the optional environment variables `BULK_SMTP_PORT`, `BULK_SMTP_SERVER`, `BULK_SMTP_LOGIN` and so on analogous to `SMTP_PORT`, `SMTP_SERVER`, `SMTP_LOGIN` and related SMTP configuration environment variables.\
|
||||
When `BULK_SMTP_SERVER` is set, this group of variables is used instead of the regular ones for sending announcement notification emails and Terms of Service notification emails.
|
||||
- **Add age verification on sign-up** (#34150, #34663, and #34636 by @ClearlyClaire and @Gargron)\
|
||||
Server administrators now have a setting to set a minimum age requirement for creating a new server, asking users for their date of birth. The date of birth is checked against the minimum age requirement server-side but not stored.\
|
||||
The following REST API changes have been made to accommodate this:
|
||||
@@ -48,10 +51,12 @@ All notable changes to this project will be documented in this file.
|
||||
- Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron)
|
||||
- Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron)
|
||||
- Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm)
|
||||
- **Add experimental FASP support** (#34031, #34415, and #34765 by @oneiros)\
|
||||
- **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033, #35218, #35262 and #35263 by @oneiros)\
|
||||
This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org).
|
||||
- Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\
|
||||
This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users.
|
||||
- Add Server Moderation Notes (#31529 by @ThisIsMissEm)
|
||||
- Add loading spinner to “Post” button when sending a post (#35153 by @diondiondion)
|
||||
- Add option to use system scrollbar styling (#32117 by @vmstan)
|
||||
- Add hover cards to follow suggestions (#33749 by @ClearlyClaire)
|
||||
- Add `t` hotkey for post translations (#33441 by @ClearlyClaire)
|
||||
@@ -59,8 +64,9 @@ All notable changes to this project will be documented in this file.
|
||||
- Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk)
|
||||
- Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\
|
||||
Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action.
|
||||
- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814 by @oneiros)\
|
||||
- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033, #35109 and #35278 by @oneiros)\
|
||||
For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests).
|
||||
- Add experimental Async Refreshes API (#34918 by @oneiros)
|
||||
- Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\
|
||||
This experimental feature causes the server to recursively fetch replies in background tasks whenever a user opens a remote post. This happens asynchronously and the client is currently not notified of the existence of new replies, which will thus only be displayed the next time this post’s context gets requested.\
|
||||
This feature needs to be explicitly enabled server-side by setting `FETCH_REPLIES_ENABLED` environment variable to `true`.
|
||||
@@ -85,7 +91,7 @@ All notable changes to this project will be documented in this file.
|
||||
- Add `og:locale` to expose status language in OpenGraph previews (#34012 by @ThisIsMissEm)
|
||||
- Add `-skip-filled-timeline` option to `tootctl feed build` to skip half-filled feeds (#33844 by @ClearlyClaire)
|
||||
- Add support for changing the base Docker registry with the `BASE_REGISTRY` `ARG` (#33712 by @wolfspyre)
|
||||
- Add an optional metric exporter (#33734, #33840, #34172, #34192, 34223)\
|
||||
- Add an optional metric exporter (#33734, #33840, #34172, #34192, #34223, and #35005 by @oneiros and @renchap)\
|
||||
Optionally enable the `prometheus_exporter` ruby gem (see https://github.com/discourse/prometheus_exporter) to collect and expose metrics. See the documentation for all the details: https://docs.joinmastodon.org/admin/config/#prometheus
|
||||
- Add `attribution_domains` attribute to `PATCH /api/v1/accounts/update_credentials` (#32730 by @c960657)\
|
||||
This is documented at https://docs.joinmastodon.org/methods/accounts/#update_credentials
|
||||
@@ -111,26 +117,31 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
### Changed
|
||||
|
||||
- Change design of navigation panel in Web UI, change layout on narrow screens (#34910, #34987, #35017, #34986, #35029, #35065, #35067, #35072, #35074, #35075, #35101, #35173, #35183, #35193 and #35225 by @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
- Change design of lists in web UI (#32881, #33054, and #33036 by @Gargron)
|
||||
- Change design of edit media modal in web UI (#33516, #33702, #33725, #33725, #33771, and #34345 by @Gargron)
|
||||
- Change design of audio player in web UI (#34520, #34740, and #34865 by @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
- Change design of audio player in web UI (#34520, #34740, #34865, #34929, #34933, and #35034 by @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
- Change design of interaction modal in web UI (#33278 by @Gargron)
|
||||
- Change list timelines to reflect added and removed users retroactively (#32930 by @Gargron)
|
||||
- Change account search to be more forgiving of spaces (#34455 by @Gargron)
|
||||
- Change unfollow button label from “Mutual” to “Unfollow” in web UI (#34392 by @Gargron)
|
||||
- Change “Specific people” to “Private mention” in menu in web UI (#33963 by @Gargron)
|
||||
- Change "Explore" to "Trending" and remove explanation banners (#34985 by @Gargron)
|
||||
- Change media attachments of moderated posts to not be accessible (#34872 by @Gargron)
|
||||
Moderators will still be able to access them while they are kept, but they won't be accessible to the public in the meantime.
|
||||
- Change language names in compose box language picker to be localized (#33402 by @c960657)
|
||||
- Change onboarding flow in web UI (#32998, #33119, and #33471 by @ClearlyClaire and @Gargron)
|
||||
- Change onboarding flow in web UI (#32998, #33119, #33471 and #34962 by @ClearlyClaire and @Gargron)
|
||||
- Change Advanced Web UI to use the new main menu instead of the “Getting started” column (#35117 by @diondiondion)
|
||||
- Change emoji categories in admin interface to be ordered by name (#33630 by @ShadowJonathan)
|
||||
- Change design of rich text elements in web UI (#32633 by @Gargron)
|
||||
- Change wording of “single choice” to “pick one” in poll authoring form (#32397 by @ThisIsMissEm)
|
||||
- Change returned favorite and boost counts to use those provided by the remote server, if available (#32620, #34594, #34618, and #34619 by @ClearlyClaire and @sneakers-the-rat)
|
||||
- Change label of favourite notifications on private mentions (#31659 by @ClearlyClaire)
|
||||
- Change wording of "discard draft?" confirmation dialogs (#35192 by @diondiondion)
|
||||
- Change `libvips` to be enabled by default in place of ImageMagick (#34741 and #34753 by @ClearlyClaire and @diondiondion)
|
||||
- Change avatar and header size limits from 2MB to 8MB when using libvips (#33002 by @Gargron)
|
||||
- Change search to use query params in web UI (#32949 and #33670 by @ClearlyClaire and @Gargron)
|
||||
- Change build system from Webpack to Vite (#34454, #34450, #34758, #34768, #34813, #34808, #34837, and #34732 by @ChaosExAnima, @ClearlyClaire, @mjankowski, and @renchap)\
|
||||
One known limitation is that themes’ main style file needs to have a very specific file name: `app/javascript/styles/:name.scss` where `:name` is the name of the theme in `config/themes.yml`
|
||||
- Change build system from Webpack to Vite (#34454, #34450, #34758, #34768, #34813, #34808, #34837, #34732, #35007, #35035 and #35177 by @ChaosExAnima, @ClearlyClaire, @mjankowski, and @renchap)
|
||||
- Change account creation API to forbid creation from user tokens (#34828 by @ThisIsMissEm)
|
||||
- Change `/api/v2/instance` to be enabled without authentication when limited federation mode is enabled (#34576 by @ClearlyClaire)
|
||||
- Change `DEFAULT_LOCALE` to not override unauthenticated users’ browser language (#34535 by @ClearlyClaire)\
|
||||
@@ -198,22 +209,44 @@ All notable changes to this project will be documented in this file.
|
||||
- Fix not being able to scroll dropdown on touch devices in web UI (#34873 by @Gargron)
|
||||
- Fix inconsistent filtering of silenced accounts for other silenced accounts (#34863 by @ClearlyClaire)
|
||||
- Fix update checker listing updates older or equal to current running version (#33906 by @ClearlyClaire)
|
||||
- Fix clicking a status multiple times causing duplicate entries in browser history (#35118 by @ClearlyClaire)
|
||||
- Fix “Alt text” button submitting form in moderation interface (#35147 by @ClearlyClaire)
|
||||
- Fix Firefox sometimes not updating spellcheck language in textarea (#35148 by @ClearlyClaire)
|
||||
- Fix `NoMethodError` in edge case of emoji cache handling (#34749 by @dariusk)
|
||||
- Fix handling of inlined `featured` collections in ActivityPub actor objects (#34789 and #34811 by @ClearlyClaire)
|
||||
- Fix long link names in admin sidebar being truncated (#34727 by @diondiondion)
|
||||
- Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire)
|
||||
- Fix OIDC account creation failing for long display names (#34639 by @defnull)
|
||||
- Fix use of the deprecated `/api/v1/instance` endpoint in the moderation interface (#34613 by @renchap)
|
||||
- Fix inaccessible “Clear search” button (#35152 and #35281 by @diondiondion)
|
||||
- Fix search operators sometimes getting lost (#35190 by @ClearlyClaire)
|
||||
- Fix directory scroll position reset (#34560 by @przucidlo)
|
||||
- Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent)
|
||||
- Fix avatar sizing with long account name in some UI elements (#34514 by @gomasy)
|
||||
- Fix empty menu section in status dropdown (#34431 by @ClearlyClaire)
|
||||
- Fix the delete suggestion button not working (#34396 and #34398 by @ClearlyClaire and @renchap)
|
||||
- Fix popover/dialog backgrounds not being blurred on older Webkit browsers (#35220 by @diondiondion)
|
||||
- Fix radio buttons not always being correctly centered (#34389 by @ChaosExAnima)
|
||||
- Fix visual glitches with adding post filters (#34387 by @ChaosExAnima)
|
||||
- Fix bugs with upload progress (#34325 by @ChaosExAnima)
|
||||
- Fix being unable to hide controls in full screen video in web UI (#34308 by @Gargron)
|
||||
- Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire)
|
||||
- Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion)
|
||||
- Fix zoomed images being blurry in Safari (#35052 by @diondiondion)
|
||||
- Fix redundant focus stop within status component in Web UI and make focus style more noticeable (#35037, #35051, #35096, #35150 and #35251 by @diondiondion)
|
||||
- Fix digits in media player time readout not having a consistent width (#35038 by @diondiondion)
|
||||
- Fix wrong text color for “Open in advanced web interface” banner in high-contrast theme (#35032 by @diondiondion)
|
||||
- Fix hover card for limited accounts not hiding information as expected (#35024 by @diondiondion)
|
||||
- Fix some animations not respecting the reduced animation preferences (#35018 by @ChaosExAnima)
|
||||
- Fix direction of media gallery arrows in RTL locales (#35014 by @diondiondion)
|
||||
- Fix cramped layout of follower recommendations on small viewports (#34967 and #35023 by @diondiondion)
|
||||
- Fix two composers being shown at the same time in some cases (#35006 by @ChaosExAnima)
|
||||
- Fix handling of remote attachments with multiple media types (#34996 by @ClearlyClaire)
|
||||
- Fix broken colors in some themed SVGs in web UI (#34988 by @Gargron)
|
||||
- Fix wrong dimensions on blurhash previews of news articles in web UI (#34990 by @Gargron)
|
||||
- Fix wrong styles on action bar in media modal in web UI (#34989 by @Gargron)
|
||||
- Fix search column input not updating on param change (#34951 by @PGrayCS)
|
||||
- Fix account note textarea being interactable before the relationship gets fetched (#34932 by @ClearlyClaire)
|
||||
- Fix SASS deprecation notices (#34278 by @ChaosExAnima)
|
||||
- Fix display of failed-to-load image attachments in web UI (#34217 by @Gargron)
|
||||
- Fix duplicate REST API requests on submitting account personal note with ctrl+enter (#34213 by @ClearlyClaire)
|
||||
|
||||
@@ -186,7 +186,7 @@ FROM build AS libvips
|
||||
|
||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||
ARG VIPS_VERSION=8.16.1
|
||||
ARG VIPS_VERSION=8.17.0
|
||||
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
||||
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||
|
||||
|
||||
8
Gemfile
8
Gemfile
@@ -53,7 +53,7 @@ gem 'fastimage'
|
||||
gem 'hiredis', '~> 0.6'
|
||||
gem 'hiredis-client'
|
||||
gem 'htmlentities', '~> 4.3'
|
||||
gem 'http', '~> 5.2.0'
|
||||
gem 'http', '~> 5.3.0'
|
||||
gem 'http_accept_language', '~> 2.1'
|
||||
gem 'httplog', '~> 1.7.0', require: false
|
||||
gem 'i18n'
|
||||
@@ -110,8 +110,8 @@ group :opentelemetry do
|
||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false
|
||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false
|
||||
gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false
|
||||
@@ -137,7 +137,7 @@ group :test do
|
||||
|
||||
# Browser integration testing
|
||||
gem 'capybara', '~> 3.39'
|
||||
gem 'selenium-webdriver'
|
||||
gem 'capybara-playwright-driver'
|
||||
|
||||
# Used to reset the database between system tests
|
||||
gem 'database_cleaner-active_record'
|
||||
|
||||
81
Gemfile.lock
81
Gemfile.lock
@@ -90,7 +90,9 @@ GEM
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
android_key_attestation (0.3.0)
|
||||
annotaterb (4.14.0)
|
||||
annotaterb (4.16.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.3.2)
|
||||
@@ -111,15 +113,15 @@ GEM
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-blob (0.5.8)
|
||||
rexml
|
||||
base64 (0.2.0)
|
||||
base64 (0.3.0)
|
||||
bcp47_spec (0.2.1)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.0)
|
||||
benchmark (0.4.1)
|
||||
better_errors (2.10.1)
|
||||
erubi (>= 1.0.0)
|
||||
rack (>= 0.9.0)
|
||||
rouge (>= 1.0.0)
|
||||
bigdecimal (3.1.9)
|
||||
bigdecimal (3.2.2)
|
||||
bindata (2.5.1)
|
||||
binding_of_caller (1.0.1)
|
||||
debug_inspector (>= 1.2.0)
|
||||
@@ -142,6 +144,10 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
capybara-playwright-driver (0.5.6)
|
||||
addressable
|
||||
capybara
|
||||
playwright-ruby-client (>= 1.16.0)
|
||||
case_transform (0.2)
|
||||
activesupport
|
||||
cbor (0.5.9.8)
|
||||
@@ -174,7 +180,7 @@ GEM
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
debug (1.11.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
debug_inspector (1.2.0)
|
||||
@@ -218,6 +224,7 @@ GEM
|
||||
mail (~> 2.7)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (5.0.1)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
@@ -232,7 +239,7 @@ GEM
|
||||
logger
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-httpclient (2.0.1)
|
||||
faraday-httpclient (2.0.2)
|
||||
httpclient (>= 2.2)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
@@ -280,7 +287,7 @@ GEM
|
||||
activesupport (>= 5.1)
|
||||
haml (>= 4.0.6)
|
||||
railties (>= 5.1)
|
||||
haml_lint (0.62.0)
|
||||
haml_lint (0.64.0)
|
||||
haml (>= 5.0)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
@@ -297,9 +304,8 @@ GEM
|
||||
redis-client (= 0.24.0)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (5.2.0)
|
||||
http (5.3.1)
|
||||
addressable (~> 2.8)
|
||||
base64 (~> 0.1)
|
||||
http-cookie (~> 1.0)
|
||||
http-form_data (~> 2.2)
|
||||
llhttp-ffi (~> 0.5.0)
|
||||
@@ -544,10 +550,10 @@ GEM
|
||||
opentelemetry-instrumentation-excon (0.23.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-faraday (0.26.0)
|
||||
opentelemetry-instrumentation-faraday (0.27.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-http (0.24.0)
|
||||
opentelemetry-instrumentation-http (0.25.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-http_client (0.23.0)
|
||||
@@ -604,6 +610,9 @@ GEM
|
||||
pg (1.5.9)
|
||||
pghero (3.7.0)
|
||||
activerecord (>= 7.1)
|
||||
playwright-ruby-client (1.52.0)
|
||||
concurrent-ruby (>= 1.1.6)
|
||||
mime-types (>= 3.0)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
premailer (1.27.0)
|
||||
@@ -633,7 +642,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.15)
|
||||
rack (3.1.16)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-cors (3.0.0)
|
||||
@@ -692,14 +701,15 @@ GEM
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rake (13.3.0)
|
||||
rdf (3.3.2)
|
||||
bcp47_spec (~> 0.2)
|
||||
bigdecimal (~> 3.1, >= 3.1.5)
|
||||
link_header (~> 0.0, >= 0.0.8)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (6.13.1)
|
||||
rdoc (6.14.1)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
@@ -727,17 +737,17 @@ GEM
|
||||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
rspec-mocks (~> 3.13.0)
|
||||
rspec-core (3.13.3)
|
||||
rspec-core (3.13.4)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.4)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-github (3.0.0)
|
||||
rspec-core (~> 3.0)
|
||||
rspec-mocks (3.13.4)
|
||||
rspec-mocks (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (8.0.0)
|
||||
rspec-rails (8.0.1)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
@@ -750,8 +760,8 @@ GEM
|
||||
rspec-expectations (~> 3.0)
|
||||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.3)
|
||||
rubocop (1.75.8)
|
||||
rspec-support (3.13.4)
|
||||
rubocop (1.77.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -759,10 +769,10 @@ GEM
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-ast (>= 1.45.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.44.1)
|
||||
rubocop-ast (1.45.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-capybara (2.22.1)
|
||||
@@ -794,7 +804,7 @@ GEM
|
||||
ruby-saml (1.18.0)
|
||||
nokogiri (>= 1.13.10)
|
||||
rexml
|
||||
ruby-vips (2.2.3)
|
||||
ruby-vips (2.2.4)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (2.4.1)
|
||||
@@ -809,12 +819,6 @@ GEM
|
||||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.33.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
shoulda-matchers (6.5.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.3.9)
|
||||
@@ -851,8 +855,8 @@ GEM
|
||||
stoplight (4.1.1)
|
||||
redlock (~> 1.0)
|
||||
stringio (3.1.7)
|
||||
strong_migrations (2.3.0)
|
||||
activerecord (>= 7)
|
||||
strong_migrations (2.4.0)
|
||||
activerecord (>= 7.1)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
attr_required (>= 0.0.5)
|
||||
@@ -868,7 +872,7 @@ GEM
|
||||
thor (1.3.2)
|
||||
tilt (2.6.0)
|
||||
timeout (0.4.3)
|
||||
tpm-key_attestation (0.14.0)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
openssl (> 2.0)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
@@ -911,7 +915,7 @@ GEM
|
||||
zeitwerk (~> 2.2)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
webauthn (3.4.0)
|
||||
webauthn (3.4.1)
|
||||
android_key_attestation (~> 0.3.0)
|
||||
bindata (~> 2.4)
|
||||
cbor (~> 0.5.9)
|
||||
@@ -928,7 +932,6 @@ GEM
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.1)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.7)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
@@ -937,7 +940,7 @@ GEM
|
||||
xorcist (1.1.3)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.2)
|
||||
zeitwerk (2.7.3)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
@@ -956,6 +959,7 @@ DEPENDENCIES
|
||||
browser
|
||||
bundler-audit (~> 0.9)
|
||||
capybara (~> 3.39)
|
||||
capybara-playwright-driver
|
||||
charlock_holmes (~> 0.7.7)
|
||||
chewy (~> 7.3)
|
||||
climate_control
|
||||
@@ -987,7 +991,7 @@ DEPENDENCIES
|
||||
hiredis (~> 0.6)
|
||||
hiredis-client
|
||||
htmlentities (~> 4.3)
|
||||
http (~> 5.2.0)
|
||||
http (~> 5.3.0)
|
||||
http_accept_language (~> 2.1)
|
||||
httplog (~> 1.7.0)
|
||||
i18n
|
||||
@@ -1026,8 +1030,8 @@ DEPENDENCIES
|
||||
opentelemetry-instrumentation-active_model_serializers (~> 0.22.0)
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
|
||||
opentelemetry-instrumentation-excon (~> 0.23.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.26.0)
|
||||
opentelemetry-instrumentation-http (~> 0.24.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.27.0)
|
||||
opentelemetry-instrumentation-http (~> 0.25.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.23.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.23.0)
|
||||
opentelemetry-instrumentation-pg (~> 0.30.0)
|
||||
@@ -1071,7 +1075,6 @@ DEPENDENCIES
|
||||
rubyzip (~> 2.3)
|
||||
sanitize (~> 7.0)
|
||||
scenic (~> 1.7)
|
||||
selenium-webdriver
|
||||
shoulda-matchers
|
||||
sidekiq (< 8)
|
||||
sidekiq-bulk (~> 0.2.0)
|
||||
|
||||
11
SECURITY.md
11
SECURITY.md
@@ -13,8 +13,9 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 4.3.x | Yes |
|
||||
| 4.2.x | Yes |
|
||||
| < 4.2 | No |
|
||||
| Version | Supported |
|
||||
| ------- | ---------------- |
|
||||
| 4.4.x | Yes |
|
||||
| 4.3.x | Yes |
|
||||
| 4.2.x | Until 2026-01-08 |
|
||||
| < 4.2 | No |
|
||||
|
||||
@@ -14,16 +14,20 @@ module Admin
|
||||
def create
|
||||
authorize @account, :show?
|
||||
|
||||
account_action = Admin::AccountAction.new(resource_params)
|
||||
account_action.target_account = @account
|
||||
account_action.current_account = current_account
|
||||
@account_action = Admin::AccountAction.new(resource_params)
|
||||
@account_action.target_account = @account
|
||||
@account_action.current_account = current_account
|
||||
|
||||
account_action.save!
|
||||
|
||||
if account_action.with_report?
|
||||
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
|
||||
if @account_action.save
|
||||
if @account_action.with_report?
|
||||
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
|
||||
else
|
||||
redirect_to admin_account_path(@account.id)
|
||||
end
|
||||
else
|
||||
redirect_to admin_account_path(@account.id)
|
||||
@warning_presets = AccountWarningPreset.all
|
||||
|
||||
render :new
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Admin::Instances::ModerationNotesController < Admin::BaseController
|
||||
before_action :set_instance, only: [:create]
|
||||
before_action :set_instance_note, only: [:destroy]
|
||||
|
||||
def create
|
||||
authorize :instance_moderation_note, :create?
|
||||
|
||||
@instance_moderation_note = current_account.instance_moderation_notes.new(content: resource_params[:content], domain: @instance.domain)
|
||||
|
||||
if @instance_moderation_note.save
|
||||
redirect_to admin_instance_path(@instance.domain, anchor: helpers.dom_id(@instance_moderation_note)), notice: I18n.t('admin.instances.moderation_notes.created_msg')
|
||||
else
|
||||
@instance_moderation_notes = @instance.moderation_notes.includes(:account).chronological
|
||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(5)
|
||||
|
||||
render 'admin/instances/show'
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @instance_moderation_note, :destroy?
|
||||
@instance_moderation_note.destroy!
|
||||
redirect_to admin_instance_path(@instance_moderation_note.domain, anchor: 'instance-notes'), notice: I18n.t('admin.instances.moderation_notes.destroyed_msg')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resource_params
|
||||
params
|
||||
.expect(instance_moderation_note: [:content])
|
||||
end
|
||||
|
||||
def set_instance
|
||||
domain = params[:instance_id]&.strip
|
||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
|
||||
end
|
||||
|
||||
def set_instance_note
|
||||
@instance_moderation_note = InstanceModerationNote.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -14,6 +14,9 @@ module Admin
|
||||
|
||||
def show
|
||||
authorize :instance, :show?
|
||||
|
||||
@instance_moderation_note = @instance.moderation_notes.new
|
||||
@instance_moderation_notes = @instance.moderation_notes.includes(:account).chronological
|
||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(LOGS_LIMIT)
|
||||
end
|
||||
@@ -52,7 +55,8 @@ module Admin
|
||||
private
|
||||
|
||||
def set_instance
|
||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(params[:id]&.strip))
|
||||
domain = params[:id]&.strip
|
||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
|
||||
end
|
||||
|
||||
def set_instances
|
||||
|
||||
@@ -17,6 +17,9 @@ module Admin
|
||||
|
||||
def edit
|
||||
authorize @rule, :update?
|
||||
|
||||
missing_languages = RuleTranslation.languages - @rule.translations.pluck(:language)
|
||||
missing_languages.each { |lang| @rule.translations.build(language: lang) }
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
@@ -4,7 +4,7 @@ class Admin::Trends::TagsController < Admin::BaseController
|
||||
def index
|
||||
authorize :tag, :review?
|
||||
|
||||
@pending_tags_count = Tag.pending_review.async_count
|
||||
@pending_tags_count = pending_tags.async_count
|
||||
@tags = filtered_tags.page(params[:page])
|
||||
@form = Trends::TagBatch.new
|
||||
end
|
||||
@@ -22,6 +22,10 @@ class Admin::Trends::TagsController < Admin::BaseController
|
||||
|
||||
private
|
||||
|
||||
def pending_tags
|
||||
Trends::TagFilter.new(status: :pending_review).results
|
||||
end
|
||||
|
||||
def filtered_tags
|
||||
Trends::TagFilter.new(filter_params).results
|
||||
end
|
||||
|
||||
@@ -92,7 +92,7 @@ class Api::BaseController < ApplicationController
|
||||
end
|
||||
|
||||
def disallow_unauthenticated_api_access?
|
||||
ENV['DISALLOW_UNAUTHENTICATED_API_ACCESS'] == 'true' || Rails.configuration.x.limited_federation_mode
|
||||
ENV['DISALLOW_UNAUTHENTICATED_API_ACCESS'] == 'true' || Rails.configuration.x.mastodon.limited_federation_mode
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -32,7 +32,7 @@ class Api::V1::FiltersController < Api::BaseController
|
||||
ApplicationRecord.transaction do
|
||||
@filter.update!(keyword_params)
|
||||
@filter.custom_filter.assign_attributes(filter_params)
|
||||
raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.count > 1
|
||||
raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.many?
|
||||
|
||||
@filter.custom_filter.save!
|
||||
end
|
||||
|
||||
@@ -15,8 +15,9 @@ class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseCo
|
||||
if params[:date].present?
|
||||
TermsOfService.published.find_by!(effective_date: params[:date])
|
||||
else
|
||||
TermsOfService.live.first || TermsOfService.published.first! # For the case when none of the published terms have become effective yet
|
||||
TermsOfService.current
|
||||
end
|
||||
end
|
||||
not_found if @terms_of_service.nil?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController
|
||||
include AsyncRefreshesConcern
|
||||
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show]
|
||||
before_action :require_user!, only: [:show]
|
||||
|
||||
@@ -12,6 +14,8 @@ class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController
|
||||
@relationships = StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
||||
end
|
||||
|
||||
add_async_refresh_header(account_home_feed.async_refresh, retry_seconds: 5)
|
||||
|
||||
render json: @statuses,
|
||||
each_serializer: REST::StatusSerializer,
|
||||
relationships: @relationships,
|
||||
|
||||
16
app/controllers/api/v1_alpha/async_refreshes_controller.rb
Normal file
16
app/controllers/api/v1_alpha/async_refreshes_controller.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1Alpha::AsyncRefreshesController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read }
|
||||
before_action :require_user!
|
||||
|
||||
def show
|
||||
async_refresh = AsyncRefresh.find(params[:id])
|
||||
|
||||
if async_refresh
|
||||
render json: async_refresh
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V2::SearchController < Api::BaseController
|
||||
include AsyncRefreshesConcern
|
||||
include Authorization
|
||||
|
||||
RESULTS_LIMIT = (ENV['MAX_SEARCH_RESULTS'] || 20).to_i
|
||||
@@ -13,6 +14,7 @@ class Api::V2::SearchController < Api::BaseController
|
||||
before_action :remote_resolve_error, if: :remote_resolve_requested?
|
||||
end
|
||||
before_action :require_valid_pagination_options!
|
||||
before_action :handle_fasp_requests
|
||||
|
||||
def index
|
||||
@search = Search.new(search_results)
|
||||
@@ -37,6 +39,21 @@ class Api::V2::SearchController < Api::BaseController
|
||||
render json: { error: 'Search queries that resolve remote resources are not supported without authentication' }, status: 401
|
||||
end
|
||||
|
||||
def handle_fasp_requests
|
||||
return unless Mastodon::Feature.fasp_enabled?
|
||||
return if params[:q].blank?
|
||||
|
||||
# Do not schedule a new retrieval if the request is a follow-up
|
||||
# to an earlier retrieval
|
||||
return if request.headers['Mastodon-Async-Refresh-Id'].present?
|
||||
|
||||
refresh_key = "fasp:account_search:#{Digest::MD5.base64digest(params[:q])}"
|
||||
return if AsyncRefresh.new(refresh_key).running?
|
||||
|
||||
add_async_refresh_header(AsyncRefresh.create(refresh_key))
|
||||
@query_fasp = true
|
||||
end
|
||||
|
||||
def remote_resolve_requested?
|
||||
truthy_param?(:resolve)
|
||||
end
|
||||
@@ -58,7 +75,8 @@ class Api::V2::SearchController < Api::BaseController
|
||||
search_params.merge(
|
||||
resolve: truthy_param?(:resolve),
|
||||
exclude_unreviewed: truthy_param?(:exclude_unreviewed),
|
||||
following: truthy_param?(:following)
|
||||
following: truthy_param?(:following),
|
||||
query_fasp: @query_fasp
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
class Api::V2::SuggestionsController < Api::BaseController
|
||||
include Authorization
|
||||
include AsyncRefreshesConcern
|
||||
|
||||
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_suggestions
|
||||
before_action :schedule_fasp_retrieval
|
||||
|
||||
def index
|
||||
render json: @suggestions.get(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:offset].to_i), each_serializer: REST::SuggestionSerializer
|
||||
@@ -22,4 +24,18 @@ class Api::V2::SuggestionsController < Api::BaseController
|
||||
def set_suggestions
|
||||
@suggestions = AccountSuggestions.new(current_account)
|
||||
end
|
||||
|
||||
def schedule_fasp_retrieval
|
||||
return unless Mastodon::Feature.fasp_enabled?
|
||||
# Do not schedule a new retrieval if the request is a follow-up
|
||||
# to an earlier retrieval
|
||||
return if request.headers['Mastodon-Async-Refresh-Id'].present?
|
||||
|
||||
refresh_key = "fasp:follow_recommendation:#{current_account.id}"
|
||||
return if AsyncRefresh.new(refresh_key).running?
|
||||
|
||||
add_async_refresh_header(AsyncRefresh.create(refresh_key))
|
||||
|
||||
Fasp::FollowRecommendationWorker.perform_async(current_account.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -101,7 +101,7 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
def after_sign_out_path_for(_resource_or_scope)
|
||||
if ENV['OMNIAUTH_ONLY'] == 'true' && ENV['OIDC_ENABLED'] == 'true'
|
||||
if ENV['OMNIAUTH_ONLY'] == 'true' && Rails.configuration.x.omniauth.oidc_enabled?
|
||||
'/auth/auth/openid_connect/logout'
|
||||
else
|
||||
new_user_session_path
|
||||
|
||||
@@ -138,7 +138,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||
set_locale { render :rules }
|
||||
end
|
||||
|
||||
def is_flashing_format? # rubocop:disable Naming/PredicateName
|
||||
def is_flashing_format? # rubocop:disable Naming/PredicatePrefix
|
||||
if params[:action] == 'create'
|
||||
false # Disable flash messages for sign-up
|
||||
else
|
||||
|
||||
@@ -12,18 +12,7 @@ class BackupsController < ApplicationController
|
||||
BACKUP_LINK_TIMEOUT = 1.hour.freeze
|
||||
|
||||
def download
|
||||
case Paperclip::Attachment.default_options[:storage]
|
||||
when :s3, :azure
|
||||
redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.to_i), allow_other_host: true
|
||||
when :fog
|
||||
if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present?
|
||||
redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.from_now), allow_other_host: true
|
||||
else
|
||||
redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
|
||||
end
|
||||
when :filesystem
|
||||
redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
|
||||
end
|
||||
redirect_to expiring_asset_url(@backup.dump, BACKUP_LINK_TIMEOUT), allow_other_host: true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
11
app/controllers/concerns/async_refreshes_concern.rb
Normal file
11
app/controllers/concerns/async_refreshes_concern.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AsyncRefreshesConcern
|
||||
private
|
||||
|
||||
def add_async_refresh_header(async_refresh, retry_seconds: 3)
|
||||
return unless async_refresh.running?
|
||||
|
||||
response.headers['Mastodon-Async-Refresh'] = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}"
|
||||
end
|
||||
end
|
||||
@@ -24,14 +24,14 @@ module SignatureVerification
|
||||
|
||||
def signature_key_id
|
||||
signed_request.key_id
|
||||
rescue Mastodon::SignatureVerificationError
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def signed_request
|
||||
@signed_request ||= SignedRequest.new(request) if signed_request?
|
||||
rescue SignatureVerificationError
|
||||
nil
|
||||
end
|
||||
|
||||
def signature_verification_failure_reason
|
||||
@@ -64,6 +64,9 @@ module SignatureVerification
|
||||
return (@signed_request_actor = actor) if signed_request.verified?(actor)
|
||||
|
||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}"
|
||||
rescue Mastodon::MalformedHeaderError => e
|
||||
@signature_verification_failure_code = 400
|
||||
fail_with! e.message
|
||||
rescue Mastodon::SignatureVerificationError => e
|
||||
fail_with! e.message
|
||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||
@@ -82,7 +85,7 @@ module SignatureVerification
|
||||
end
|
||||
|
||||
def actor_from_key_id
|
||||
key_id = signature_key_id
|
||||
key_id = signed_request.key_id
|
||||
domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id
|
||||
|
||||
if domain_not_allowed?(domain)
|
||||
|
||||
@@ -12,7 +12,7 @@ module ThemingConcern
|
||||
def current_skin
|
||||
@current_skin ||= begin
|
||||
skins = Themes.instance.skins_for(current_flavour)
|
||||
[current_user&.setting_skin, Setting.skin, 'system', 'application'].find { |skin| skins.include?(skin) }
|
||||
[current_user&.setting_skin, Setting.skin, 'system', 'default'].find { |skin| skins.include?(skin) }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -50,6 +50,13 @@ module WebAppControllerConcern
|
||||
return unless current_user&.require_tos_interstitial?
|
||||
|
||||
@terms_of_service = TermsOfService.published.first
|
||||
|
||||
# Handle case where terms of service have been removed from the database
|
||||
if @terms_of_service.nil?
|
||||
current_user.update(require_tos_interstitial: false)
|
||||
return
|
||||
end
|
||||
|
||||
render 'terms_of_service_interstitial/show', layout: 'auth'
|
||||
end
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ class MediaProxyController < ApplicationController
|
||||
skip_before_action :require_functional!
|
||||
|
||||
before_action :authenticate_user!, if: :limited_federation_mode?
|
||||
before_action :set_media_attachment
|
||||
|
||||
rescue_from ActiveRecord::RecordInvalid, with: :not_found
|
||||
rescue_from Mastodon::UnexpectedResponseError, with: :not_found
|
||||
@@ -16,25 +17,36 @@ class MediaProxyController < ApplicationController
|
||||
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
|
||||
|
||||
def show
|
||||
with_redis_lock("media_download:#{params[:id]}") do
|
||||
@media_attachment = MediaAttachment.remote.attached.find(params[:id])
|
||||
authorize @media_attachment.status, :show?
|
||||
redownload! if @media_attachment.needs_redownload? && !reject_media?
|
||||
if @media_attachment.needs_redownload? && !reject_media?
|
||||
with_redis_lock("media_download:#{params[:id]}") do
|
||||
@media_attachment.reload # Reload once we have acquired a lock, in case the file was downloaded in the meantime
|
||||
redownload! if @media_attachment.needs_redownload?
|
||||
end
|
||||
end
|
||||
|
||||
redirect_to full_asset_url(@media_attachment.file.url(version)), allow_other_host: true
|
||||
if requires_file_streaming?
|
||||
send_file(media_attachment_file.path, type: media_attachment_file.instance_read(:content_type), disposition: 'inline')
|
||||
else
|
||||
redirect_to media_attachment_file_path, allow_other_host: true
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_media_attachment
|
||||
@media_attachment = MediaAttachment.attached.find(params[:id])
|
||||
authorize @media_attachment, :download?
|
||||
end
|
||||
|
||||
def redownload!
|
||||
@media_attachment.download_file!
|
||||
@media_attachment.download_thumbnail!
|
||||
@media_attachment.created_at = Time.now.utc
|
||||
@media_attachment.save!
|
||||
end
|
||||
|
||||
def version
|
||||
if request.path.end_with?('/small')
|
||||
def attachment_style
|
||||
if @media_attachment.thumbnail.blank? && preview_requested?
|
||||
:small
|
||||
else
|
||||
:original
|
||||
@@ -42,6 +54,30 @@ class MediaProxyController < ApplicationController
|
||||
end
|
||||
|
||||
def reject_media?
|
||||
DomainBlock.reject_media?(@media_attachment.account.domain)
|
||||
@media_attachment.account.remote? && DomainBlock.reject_media?(@media_attachment.account.domain)
|
||||
end
|
||||
|
||||
def media_attachment_file_path
|
||||
if @media_attachment.discarded?
|
||||
expiring_asset_url(media_attachment_file, 10.minutes)
|
||||
else
|
||||
full_asset_url(media_attachment_file.url(attachment_style))
|
||||
end
|
||||
end
|
||||
|
||||
def media_attachment_file
|
||||
if @media_attachment.thumbnail.present? && preview_requested?
|
||||
@media_attachment.thumbnail
|
||||
else
|
||||
@media_attachment.file
|
||||
end
|
||||
end
|
||||
|
||||
def preview_requested?
|
||||
request.path.end_with?('/small')
|
||||
end
|
||||
|
||||
def requires_file_streaming?
|
||||
Paperclip::Attachment.default_options[:storage] == :filesystem && @media_attachment.discarded?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
||||
class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
||||
skip_before_action :authenticate_resource_owner!
|
||||
|
||||
before_action :store_current_location
|
||||
before_action :authenticate_resource_owner!
|
||||
|
||||
layout 'modal'
|
||||
|
||||
content_security_policy do |p|
|
||||
p.form_action(false)
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicationsController
|
||||
class OAuth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicationsController
|
||||
skip_before_action :authenticate_resource_owner!
|
||||
|
||||
before_action :store_current_location
|
||||
@@ -11,6 +11,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
|
||||
|
||||
skip_before_action :require_functional!
|
||||
|
||||
layout 'admin'
|
||||
|
||||
include Localized
|
||||
|
||||
def destroy
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Oauth::TokensController < Doorkeeper::TokensController
|
||||
class OAuth::TokensController < Doorkeeper::TokensController
|
||||
def revoke
|
||||
unsubscribe_for_token if token.present? && authorized? && token.accessible?
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Oauth::UserinfoController < Api::BaseController
|
||||
class OAuth::UserinfoController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :profile }, only: [:show]
|
||||
before_action :require_user!
|
||||
|
||||
def show
|
||||
@account = current_account
|
||||
render json: @account, serializer: OauthUserinfoSerializer
|
||||
render json: @account, serializer: OAuthUserinfoSerializer
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module WellKnown
|
||||
class OauthMetadataController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
||||
class OAuthMetadataController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
||||
include CacheConcern
|
||||
|
||||
# Prevent `active_model_serializer`'s `ActionController::Serialization` from calling `current_user`
|
||||
@@ -13,8 +13,8 @@ module WellKnown
|
||||
# new OAuth scopes are added), we don't use expires_in to cache upstream,
|
||||
# instead just caching in the rails cache:
|
||||
render_with_cache(
|
||||
json: ::OauthMetadataPresenter.new,
|
||||
serializer: ::OauthMetadataSerializer,
|
||||
json: ::OAuthMetadataPresenter.new,
|
||||
serializer: ::OAuthMetadataSerializer,
|
||||
content_type: 'application/json',
|
||||
expires_in: 15.minutes
|
||||
)
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
module AuthorizedFetchHelper
|
||||
def authorized_fetch_mode?
|
||||
ENV.fetch('AUTHORIZED_FETCH') { Setting.authorized_fetch && 'true' } == 'true' || Rails.configuration.x.limited_federation_mode
|
||||
ENV.fetch('AUTHORIZED_FETCH') { Setting.authorized_fetch && 'true' } == 'true' || Rails.configuration.x.mastodon.limited_federation_mode
|
||||
end
|
||||
|
||||
def authorized_fetch_overridden?
|
||||
ENV.key?('AUTHORIZED_FETCH') || Rails.configuration.x.limited_federation_mode
|
||||
ENV.key?('AUTHORIZED_FETCH') || Rails.configuration.x.mastodon.limited_federation_mode
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,6 +18,6 @@ module DomainControlHelper
|
||||
end
|
||||
|
||||
def limited_federation_mode?
|
||||
Rails.configuration.x.limited_federation_mode
|
||||
Rails.configuration.x.mastodon.limited_federation_mode
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,6 +26,8 @@ module JsonLdHelper
|
||||
# The url attribute can be a string, an array of strings, or an array of objects.
|
||||
# The objects could include a mimeType. Not-included mimeType means it's text/html.
|
||||
def url_to_href(value, preferred_type = nil)
|
||||
value = [value] if value.is_a?(Hash)
|
||||
|
||||
single_value = if value.is_a?(Array) && !value.first.is_a?(String)
|
||||
value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) }
|
||||
elsif value.is_a?(Array)
|
||||
@@ -41,6 +43,15 @@ module JsonLdHelper
|
||||
end
|
||||
end
|
||||
|
||||
def url_to_media_type(value, preferred_type = nil)
|
||||
value = [value] if value.is_a?(Hash)
|
||||
return unless value.is_a?(Array) && !value.first.is_a?(String)
|
||||
|
||||
single_value = value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) }
|
||||
|
||||
single_value['mediaType'] unless single_value.nil?
|
||||
end
|
||||
|
||||
def as_array(value)
|
||||
if value.nil?
|
||||
[]
|
||||
|
||||
@@ -11,9 +11,9 @@ module MediaComponentHelper
|
||||
src: full_asset_url(video.file.url(:original)),
|
||||
preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)),
|
||||
alt: video.description,
|
||||
lang: status.language,
|
||||
blurhash: video.blurhash,
|
||||
frameRate: meta.dig('original', 'frame_rate'),
|
||||
inline: true,
|
||||
aspectRatio: "#{meta.dig('original', 'width')} / #{meta.dig('original', 'height')}",
|
||||
media: [
|
||||
serialize_media_attachment(video),
|
||||
@@ -34,6 +34,8 @@ module MediaComponentHelper
|
||||
src: full_asset_url(audio.file.url(:original)),
|
||||
poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url),
|
||||
alt: audio.description,
|
||||
lang: status.language,
|
||||
blurhash: audio.blurhash,
|
||||
backgroundColor: meta.dig('colors', 'background'),
|
||||
foregroundColor: meta.dig('colors', 'foreground'),
|
||||
accentColor: meta.dig('colors', 'accent'),
|
||||
|
||||
@@ -20,6 +20,21 @@ module RoutingHelper
|
||||
URI.join(asset_host, source).to_s
|
||||
end
|
||||
|
||||
def expiring_asset_url(attachment, expires_in)
|
||||
case Paperclip::Attachment.default_options[:storage]
|
||||
when :s3, :azure
|
||||
attachment.expiring_url(expires_in.to_i)
|
||||
when :fog
|
||||
if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present?
|
||||
attachment.expiring_url(expires_in.from_now)
|
||||
else
|
||||
full_asset_url(attachment.url)
|
||||
end
|
||||
when :filesystem
|
||||
full_asset_url(attachment.url)
|
||||
end
|
||||
end
|
||||
|
||||
def asset_host
|
||||
Rails.configuration.action_controller.asset_host || root_url
|
||||
end
|
||||
|
||||
@@ -6,13 +6,11 @@ module ThemeHelper
|
||||
|
||||
if theme == 'system'
|
||||
''.html_safe.tap do |tags|
|
||||
tags << vite_stylesheet_tag("skins/#{flavour}/mastodon-light.scss", media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous')
|
||||
tags << vite_stylesheet_tag("skins/#{flavour}/application.scss", media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous')
|
||||
tags << vite_stylesheet_tag("skins/#{flavour}/mastodon-light", type: :virtual, media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous')
|
||||
tags << vite_stylesheet_tag("skins/#{flavour}/default", type: :virtual, media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous')
|
||||
end
|
||||
elsif theme == 'default'
|
||||
vite_stylesheet_tag "skins/#{flavour}/application.scss", media: 'all', crossorigin: 'anonymous'
|
||||
else
|
||||
vite_stylesheet_tag "skins/#{flavour}/#{theme}.scss", media: 'all', crossorigin: 'anonymous'
|
||||
vite_stylesheet_tag "skins/#{flavour}/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous'
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { browserHistory } from 'flavours/glitch/components/router';
|
||||
import { debounceWithDispatchAndArguments } from 'flavours/glitch/utils/debounce';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
import {
|
||||
followAccountSuccess, unfollowAccountSuccess,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
blockAccountSuccess, unblockAccountSuccess,
|
||||
pinAccountSuccess, unpinAccountSuccess,
|
||||
fetchRelationshipsSuccess,
|
||||
fetchEndorsedAccounts,
|
||||
} from './accounts_typed';
|
||||
import { importFetchedAccount, importFetchedAccounts } from './importer';
|
||||
|
||||
@@ -634,6 +636,7 @@ export function pinAccount(id) {
|
||||
|
||||
api().post(`/api/v1/accounts/${id}/pin`).then(response => {
|
||||
dispatch(pinAccountSuccess({ relationship: response.data }));
|
||||
dispatch(fetchEndorsedAccounts({ accountId: me }));
|
||||
}).catch(error => {
|
||||
dispatch(pinAccountFail(error));
|
||||
});
|
||||
@@ -646,6 +649,7 @@ export function unpinAccount(id) {
|
||||
|
||||
api().post(`/api/v1/accounts/${id}/unpin`).then(response => {
|
||||
dispatch(unpinAccountSuccess({ relationship: response.data }));
|
||||
dispatch(fetchEndorsedAccounts({ accountId: me }));
|
||||
}).catch(error => {
|
||||
dispatch(unpinAccountFail(error));
|
||||
});
|
||||
|
||||
@@ -4,14 +4,12 @@ export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
|
||||
export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
|
||||
export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL';
|
||||
|
||||
export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST';
|
||||
export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS';
|
||||
export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL';
|
||||
|
||||
export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST';
|
||||
export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS';
|
||||
export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL';
|
||||
|
||||
export * from './lists_typed';
|
||||
|
||||
export const fetchList = id => (dispatch, getState) => {
|
||||
if (getState().getIn(['lists', id])) {
|
||||
return;
|
||||
@@ -40,28 +38,6 @@ export const fetchListFail = (id, error) => ({
|
||||
error,
|
||||
});
|
||||
|
||||
export const fetchLists = () => (dispatch) => {
|
||||
dispatch(fetchListsRequest());
|
||||
|
||||
api().get('/api/v1/lists')
|
||||
.then(({ data }) => dispatch(fetchListsSuccess(data)))
|
||||
.catch(err => dispatch(fetchListsFail(err)));
|
||||
};
|
||||
|
||||
export const fetchListsRequest = () => ({
|
||||
type: LISTS_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
export const fetchListsSuccess = lists => ({
|
||||
type: LISTS_FETCH_SUCCESS,
|
||||
lists,
|
||||
});
|
||||
|
||||
export const fetchListsFail = error => ({
|
||||
type: LISTS_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const deleteList = id => (dispatch) => {
|
||||
dispatch(deleteListRequest(id));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiCreate, apiUpdate } from 'flavours/glitch/api/lists';
|
||||
import { apiCreate, apiUpdate, apiGetLists } from 'flavours/glitch/api/lists';
|
||||
import type { List } from 'flavours/glitch/models/list';
|
||||
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
@@ -11,3 +11,7 @@ export const updateList = createDataLoadingThunk(
|
||||
'list/update',
|
||||
(list: Partial<List>) => apiUpdate(list),
|
||||
);
|
||||
|
||||
export const fetchLists = createDataLoadingThunk('lists/fetch', () =>
|
||||
apiGetLists(),
|
||||
);
|
||||
|
||||
7
app/javascript/flavours/glitch/actions/navigation.ts
Normal file
7
app/javascript/flavours/glitch/actions/navigation.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
export const openNavigation = createAction('navigation/open');
|
||||
|
||||
export const closeNavigation = createAction('navigation/close');
|
||||
|
||||
export const toggleNavigation = createAction('navigation/toggle');
|
||||
@@ -1,12 +1,30 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import {
|
||||
apiGetTag,
|
||||
apiFollowTag,
|
||||
apiUnfollowTag,
|
||||
apiFeatureTag,
|
||||
apiUnfeatureTag,
|
||||
apiGetFollowedTags,
|
||||
} from 'flavours/glitch/api/tags';
|
||||
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
export const fetchFollowedHashtags = createDataLoadingThunk(
|
||||
'tags/fetch-followed',
|
||||
async ({ next }: { next?: string } = {}) => {
|
||||
const response = await apiGetFollowedTags(next);
|
||||
return {
|
||||
...response,
|
||||
replace: !next,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const markFollowedHashtagsStale = createAction(
|
||||
'tags/mark-followed-stale',
|
||||
);
|
||||
|
||||
export const fetchHashtag = createDataLoadingThunk(
|
||||
'tags/fetch',
|
||||
({ tagId }: { tagId: string }) => apiGetTag(tagId),
|
||||
@@ -15,6 +33,9 @@ export const fetchHashtag = createDataLoadingThunk(
|
||||
export const followHashtag = createDataLoadingThunk(
|
||||
'tags/follow',
|
||||
({ tagId }: { tagId: string }) => apiFollowTag(tagId),
|
||||
(_, { dispatch }) => {
|
||||
void dispatch(markFollowedHashtagsStale());
|
||||
},
|
||||
);
|
||||
|
||||
export const unfollowHashtag = createDataLoadingThunk(
|
||||
|
||||
@@ -13,6 +13,8 @@ export const apiCreate = (list: Partial<ApiListJSON>) =>
|
||||
export const apiUpdate = (list: Partial<ApiListJSON>) =>
|
||||
apiRequestPut<ApiListJSON>(`v1/lists/${list.id}`, list);
|
||||
|
||||
export const apiGetLists = () => apiRequestGet<ApiListJSON[]>('v1/lists');
|
||||
|
||||
export const apiGetAccounts = (listId: string) =>
|
||||
apiRequestGet<ApiAccountJSON[]>(`v1/lists/${listId}/accounts`, {
|
||||
limit: 0,
|
||||
|
||||
@@ -20,10 +20,11 @@ export const apiFeatureTag = (tagId: string) =>
|
||||
export const apiUnfeatureTag = (tagId: string) =>
|
||||
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfeature`);
|
||||
|
||||
export const apiGetFollowedTags = async (url?: string) => {
|
||||
export const apiGetFollowedTags = async (url?: string, limit?: number) => {
|
||||
const response = await api().request<ApiHashtagJSON[]>({
|
||||
method: 'GET',
|
||||
url: url ?? '/api/v1/followed_tags',
|
||||
params: { limit },
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
muteAccount,
|
||||
unmuteAccount,
|
||||
followAccountSuccess,
|
||||
unpinAccount,
|
||||
pinAccount,
|
||||
} from 'flavours/glitch/actions/accounts';
|
||||
import { showAlertForError } from 'flavours/glitch/actions/alerts';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
@@ -63,14 +65,25 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
export const Account: React.FC<{
|
||||
interface AccountProps {
|
||||
size?: number;
|
||||
id: string;
|
||||
hidden?: boolean;
|
||||
minimal?: boolean;
|
||||
defaultAction?: 'block' | 'mute';
|
||||
withBio?: boolean;
|
||||
}> = ({ id, size = 46, hidden, minimal, defaultAction, withBio }) => {
|
||||
withMenu?: boolean;
|
||||
}
|
||||
|
||||
export const Account: React.FC<AccountProps> = ({
|
||||
id,
|
||||
size = 46,
|
||||
hidden,
|
||||
minimal,
|
||||
defaultAction,
|
||||
withBio,
|
||||
withMenu = true,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { signedIn } = useIdentity();
|
||||
const account = useAppSelector((state) => state.accounts.get(id));
|
||||
@@ -120,8 +133,6 @@ export const Account: React.FC<{
|
||||
},
|
||||
];
|
||||
} else if (defaultAction !== 'block') {
|
||||
arr = [];
|
||||
|
||||
if (isRemote && accountUrl) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
@@ -174,6 +185,25 @@ export const Account: React.FC<{
|
||||
text: intl.formatMessage(messages.addToLists),
|
||||
action: handleAddToLists,
|
||||
});
|
||||
|
||||
if (id !== me && (relationship?.following || relationship?.requested)) {
|
||||
const handleEndorseToggle = () => {
|
||||
if (relationship.endorsed) {
|
||||
dispatch(unpinAccount(id));
|
||||
} else {
|
||||
dispatch(pinAccount(id));
|
||||
}
|
||||
};
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
// Defined in features/account_timeline/components/account_header.tsx
|
||||
relationship.endorsed
|
||||
? { id: 'account.unendorse' }
|
||||
: { id: 'account.endorse' },
|
||||
),
|
||||
action: handleEndorseToggle,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,9 +228,10 @@ export const Account: React.FC<{
|
||||
);
|
||||
}
|
||||
|
||||
let button: React.ReactNode, dropdown: React.ReactNode;
|
||||
let button: React.ReactNode;
|
||||
let dropdown: React.ReactNode;
|
||||
|
||||
if (menu.length > 0) {
|
||||
if (menu.length > 0 && withMenu) {
|
||||
dropdown = (
|
||||
<Dropdown
|
||||
items={menu}
|
||||
@@ -252,43 +283,69 @@ export const Account: React.FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('account', { 'account--minimal': minimal })}>
|
||||
<div className='account__wrapper'>
|
||||
<Permalink
|
||||
className='account__display-name'
|
||||
title={account?.acct}
|
||||
href={account?.url}
|
||||
to={`/@${account?.acct}`}
|
||||
data-hover-card-account={id}
|
||||
>
|
||||
<div className='account__avatar-wrapper'>
|
||||
{account ? (
|
||||
<Avatar account={account} size={size} />
|
||||
<div
|
||||
className={classNames('account', {
|
||||
'account--minimal': minimal,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={classNames('account__wrapper', {
|
||||
'account__wrapper--with-bio': account && withBio,
|
||||
})}
|
||||
>
|
||||
<div className='account__info-wrapper'>
|
||||
<Permalink
|
||||
className='account__display-name'
|
||||
title={account?.acct}
|
||||
href={account?.url}
|
||||
to={`/@${account?.acct}`}
|
||||
data-hover-card-account={id}
|
||||
>
|
||||
<div className='account__avatar-wrapper'>
|
||||
{account ? (
|
||||
<Avatar account={account} size={size} />
|
||||
) : (
|
||||
<Skeleton width={size} height={size} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='account__contents'>
|
||||
<DisplayName account={account} />
|
||||
|
||||
{!minimal && (
|
||||
<div className='account__details'>
|
||||
{account ? (
|
||||
<>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>{' '}
|
||||
{verification} {muteTimeRemaining}
|
||||
</>
|
||||
) : (
|
||||
<Skeleton width='7ch' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Permalink>
|
||||
|
||||
{account &&
|
||||
withBio &&
|
||||
(account.note.length > 0 ? (
|
||||
<div
|
||||
className='account__note translate'
|
||||
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton width={size} height={size} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='account__contents'>
|
||||
<DisplayName account={account} />
|
||||
|
||||
{!minimal && (
|
||||
<div className='account__details'>
|
||||
{account ? (
|
||||
<>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>{' '}
|
||||
{verification} {muteTimeRemaining}
|
||||
</>
|
||||
) : (
|
||||
<Skeleton width='7ch' />
|
||||
)}
|
||||
<div className='account__note account__note--missing'>
|
||||
<FormattedMessage
|
||||
id='account.no_bio'
|
||||
defaultMessage='No description provided.'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Permalink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!minimal && (
|
||||
<div className='account__relationship'>
|
||||
@@ -297,22 +354,6 @@ export const Account: React.FC<{
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{account &&
|
||||
withBio &&
|
||||
(account.note.length > 0 ? (
|
||||
<div
|
||||
className='account__note translate'
|
||||
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
||||
/>
|
||||
) : (
|
||||
<div className='account__note account__note--missing'>
|
||||
<FormattedMessage
|
||||
id='account.no_bio'
|
||||
defaultMessage='No description provided.'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ export const AltTextBadge: React.FC<{
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
ref={anchorRef}
|
||||
className='media-gallery__alt__label'
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -162,6 +162,14 @@ const AutosuggestTextarea = forwardRef(({
|
||||
}
|
||||
}, [suggestions, textareaRef, setSuggestionsHidden]);
|
||||
|
||||
// Hack to force Firefox to change language in autocorrect
|
||||
useEffect(() => {
|
||||
if (lang && textareaRef.current && textareaRef.current === document.activeElement) {
|
||||
textareaRef.current.blur();
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [lang]);
|
||||
|
||||
const renderSuggestion = (suggestion, i) => {
|
||||
let inner, key;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ interface Props {
|
||||
withLink?: boolean;
|
||||
counter?: number | string;
|
||||
counterBorderColor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Avatar: React.FC<Props> = ({
|
||||
@@ -27,6 +28,7 @@ export const Avatar: React.FC<Props> = ({
|
||||
inline = false,
|
||||
withLink = false,
|
||||
style: styleFromParent,
|
||||
className,
|
||||
counter,
|
||||
counterBorderColor,
|
||||
}) => {
|
||||
@@ -52,7 +54,7 @@ export const Avatar: React.FC<Props> = ({
|
||||
|
||||
const avatar = (
|
||||
<div
|
||||
className={classNames('account__avatar', {
|
||||
className={classNames(className, 'account__avatar', {
|
||||
'account__avatar--inline': inline,
|
||||
'account__avatar--loading': loading,
|
||||
})}
|
||||
|
||||
@@ -3,12 +3,15 @@ import { useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
|
||||
interface BaseProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
block?: boolean;
|
||||
secondary?: boolean;
|
||||
compact?: boolean;
|
||||
dangerous?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface PropsChildren extends PropsWithChildren<BaseProps> {
|
||||
@@ -22,6 +25,10 @@ interface PropsWithText extends BaseProps {
|
||||
|
||||
type Props = PropsWithText | PropsChildren;
|
||||
|
||||
/**
|
||||
* Primary UI component for user interaction that doesn't result in navigation.
|
||||
*/
|
||||
|
||||
export const Button: React.FC<Props> = ({
|
||||
type = 'button',
|
||||
onClick,
|
||||
@@ -30,6 +37,7 @@ export const Button: React.FC<Props> = ({
|
||||
secondary,
|
||||
compact,
|
||||
dangerous,
|
||||
loading,
|
||||
className,
|
||||
title,
|
||||
text,
|
||||
@@ -38,13 +46,18 @@ export const Button: React.FC<Props> = ({
|
||||
}) => {
|
||||
const handleClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
|
||||
(e) => {
|
||||
if (!disabled && onClick) {
|
||||
if (disabled || loading) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
} else if (onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
},
|
||||
[disabled, onClick],
|
||||
[disabled, loading, onClick],
|
||||
);
|
||||
|
||||
const label = text ?? children;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames('button', className, {
|
||||
@@ -52,14 +65,27 @@ export const Button: React.FC<Props> = ({
|
||||
'button--compact': compact,
|
||||
'button--block': block,
|
||||
'button--dangerous': dangerous,
|
||||
loading,
|
||||
})}
|
||||
disabled={disabled}
|
||||
// Disabled buttons can't have focus, so we don't really
|
||||
// disable the button during loading
|
||||
disabled={disabled && !loading}
|
||||
aria-disabled={loading}
|
||||
// If the loading prop is used, announce label changes
|
||||
aria-live={loading !== undefined ? 'polite' : undefined}
|
||||
onClick={handleClick}
|
||||
title={title}
|
||||
type={type}
|
||||
{...props}
|
||||
>
|
||||
{text ?? children}
|
||||
{loading ? (
|
||||
<>
|
||||
<span className='button__label-wrapper'>{label}</span>
|
||||
<LoadingIndicator role='none' />
|
||||
</>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,8 @@ import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
||||
import UnfoldLessIcon from '@/material-icons/400-24px/unfold_less.svg?react';
|
||||
import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
|
||||
import type { IconProp } from 'flavours/glitch/components/icon';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context';
|
||||
@@ -17,7 +18,7 @@ import { useIdentity } from 'flavours/glitch/identity_context';
|
||||
|
||||
import { useAppHistory } from './router';
|
||||
|
||||
const messages = defineMessages({
|
||||
export const messages = defineMessages({
|
||||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
||||
moveLeft: {
|
||||
@@ -238,7 +239,10 @@ export const ColumnHeader: React.FC<Props> = ({
|
||||
onClick={handleToggleClick}
|
||||
>
|
||||
<i className='icon-with-badge'>
|
||||
<Icon id='sliders' icon={SettingsIcon} />
|
||||
<Icon
|
||||
id='sliders'
|
||||
icon={collapsed ? UnfoldMoreIcon : UnfoldLessIcon}
|
||||
/>
|
||||
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
|
||||
</i>
|
||||
</button>
|
||||
|
||||
@@ -20,6 +20,6 @@ export const ContentWarning: React.FC<{
|
||||
key={`icon-${icon}`}
|
||||
/>
|
||||
))}
|
||||
<p dangerouslySetInnerHTML={{ __html: text }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: text }} />
|
||||
</StatusBanner>
|
||||
);
|
||||
|
||||
@@ -12,15 +12,13 @@ export const FilterWarning: React.FC<{
|
||||
onClick={onClick}
|
||||
variant={BannerVariant.Filter}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='filter_warning.matches_filter'
|
||||
defaultMessage='Matches filter “<span>{title}</span>”'
|
||||
values={{
|
||||
title,
|
||||
span: (chunks) => <span className='filter-name'>{chunks}</span>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<FormattedMessage
|
||||
id='filter_warning.matches_filter'
|
||||
defaultMessage='Matches filter “<span>{title}</span>”'
|
||||
values={{
|
||||
title,
|
||||
span: (chunks) => <span className='filter-name'>{chunks}</span>,
|
||||
}}
|
||||
/>
|
||||
</StatusBanner>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Permalink } from 'flavours/glitch/components/permalink';
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import { useFetchFamiliarFollowers } from 'flavours/glitch/features/account_timeline/hooks/familiar_followers';
|
||||
import { domain } from 'flavours/glitch/initial_state';
|
||||
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
export const HoverCardAccount = forwardRef<
|
||||
@@ -31,6 +32,11 @@ export const HoverCardAccount = forwardRef<
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
const suspended = account?.suspended;
|
||||
const hidden = useAppSelector((state) =>
|
||||
accountId ? getAccountHidden(state, accountId) : undefined,
|
||||
);
|
||||
const isSuspendedOrHidden = Boolean(suspended || hidden);
|
||||
|
||||
const note = useAppSelector(
|
||||
(state) =>
|
||||
@@ -74,69 +80,95 @@ export const HoverCardAccount = forwardRef<
|
||||
href={account.get('url')}
|
||||
className='hover-card__name'
|
||||
>
|
||||
<Avatar account={account} size={46} />
|
||||
<Avatar
|
||||
account={isSuspendedOrHidden ? undefined : account}
|
||||
size={46}
|
||||
/>
|
||||
<DisplayName account={account} localDomain={domain} />
|
||||
</Permalink>
|
||||
|
||||
<div className='hover-card__text-row'>
|
||||
<AccountBio
|
||||
note={account.note_emojified}
|
||||
className='hover-card__bio'
|
||||
/>
|
||||
<AccountFields fields={account.fields} limit={2} />
|
||||
{note && note.length > 0 && (
|
||||
<dl className='hover-card__note'>
|
||||
<dt className='hover-card__note-label'>
|
||||
<FormattedMessage
|
||||
id='account.account_note_header'
|
||||
defaultMessage='Personal note'
|
||||
/>
|
||||
</dt>
|
||||
<dd>{note}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='hover-card__numbers'>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>
|
||||
{shouldDisplayFamiliarFollowers && (
|
||||
<>
|
||||
·
|
||||
<div className='hover-card__familiar-followers'>
|
||||
<ShortNumber
|
||||
value={familiarFollowers.length}
|
||||
renderer={FollowersYouKnowCounter}
|
||||
/>
|
||||
<AvatarGroup compact>
|
||||
{familiarFollowers.slice(0, 3).map((account) => (
|
||||
<Avatar key={account.id} account={account} size={22} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(isMutual || isFollower) && (
|
||||
<>
|
||||
·
|
||||
{isMutual ? (
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='account.follows_you'
|
||||
defaultMessage='Follows you'
|
||||
/>
|
||||
{isSuspendedOrHidden ? (
|
||||
<div className='hover-card__limited-account-note'>
|
||||
{suspended ? (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_suspended'
|
||||
defaultMessage='Account suspended'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='limited_account_hint.title'
|
||||
defaultMessage='This profile has been hidden by the moderators of {domain}.'
|
||||
values={{ domain }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='hover-card__text-row'>
|
||||
<AccountBio
|
||||
note={account.note_emojified}
|
||||
className='hover-card__bio'
|
||||
/>
|
||||
<AccountFields fields={account.fields} limit={2} />
|
||||
{note && note.length > 0 && (
|
||||
<dl className='hover-card__note'>
|
||||
<dt className='hover-card__note-label'>
|
||||
<FormattedMessage
|
||||
id='account.account_note_header'
|
||||
defaultMessage='Personal note'
|
||||
/>
|
||||
</dt>
|
||||
<dd>{note}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FollowButton accountId={accountId} />
|
||||
<div className='hover-card__numbers'>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>
|
||||
{shouldDisplayFamiliarFollowers && (
|
||||
<>
|
||||
·
|
||||
<div className='hover-card__familiar-followers'>
|
||||
<ShortNumber
|
||||
value={familiarFollowers.length}
|
||||
renderer={FollowersYouKnowCounter}
|
||||
/>
|
||||
<AvatarGroup compact>
|
||||
{familiarFollowers.slice(0, 3).map((account) => (
|
||||
<Avatar
|
||||
key={account.id}
|
||||
account={account}
|
||||
size={22}
|
||||
/>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(isMutual || isFollower) && (
|
||||
<>
|
||||
·
|
||||
{isMutual ? (
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='account.follows_you'
|
||||
defaultMessage='Follows you'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FollowButton accountId={accountId} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
|
||||
@@ -13,14 +13,13 @@ interface Props extends React.SVGProps<SVGSVGElement> {
|
||||
children?: never;
|
||||
id: string;
|
||||
icon: IconProp;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const Icon: React.FC<Props> = ({
|
||||
id,
|
||||
icon: IconComponent,
|
||||
className,
|
||||
title: titleProp,
|
||||
'aria-label': ariaLabel,
|
||||
...other
|
||||
}) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
@@ -34,18 +33,19 @@ export const Icon: React.FC<Props> = ({
|
||||
IconComponent = CheckBoxOutlineBlankIcon;
|
||||
}
|
||||
|
||||
const ariaHidden = titleProp ? undefined : true;
|
||||
const ariaHidden = ariaLabel ? undefined : true;
|
||||
const role = !ariaHidden ? 'img' : undefined;
|
||||
|
||||
// Set the title to an empty string to remove the built-in SVG one if any
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const title = titleProp || '';
|
||||
const title = ariaLabel || '';
|
||||
|
||||
return (
|
||||
<IconComponent
|
||||
className={classNames('icon', `icon-${id}`, className)}
|
||||
title={title}
|
||||
aria-hidden={ariaHidden}
|
||||
aria-label={ariaLabel}
|
||||
role={role}
|
||||
{...other}
|
||||
/>
|
||||
|
||||
@@ -27,6 +27,7 @@ interface Props {
|
||||
counter?: number;
|
||||
href?: string;
|
||||
ariaHidden?: boolean;
|
||||
ariaControls?: string;
|
||||
label?: string;
|
||||
obfuscateCount?: boolean;
|
||||
}
|
||||
@@ -54,6 +55,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||
overlay = false,
|
||||
tabIndex = 0,
|
||||
ariaHidden = false,
|
||||
ariaControls,
|
||||
label,
|
||||
obfuscateCount,
|
||||
},
|
||||
@@ -158,6 +160,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||
aria-label={title}
|
||||
aria-expanded={expanded}
|
||||
aria-hidden={ariaHidden}
|
||||
aria-controls={ariaControls}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -7,7 +7,7 @@ interface Props {
|
||||
id: string;
|
||||
icon: IconProp;
|
||||
count: number;
|
||||
issueBadge: boolean;
|
||||
issueBadge?: boolean;
|
||||
className: string;
|
||||
}
|
||||
export const IconWithBadge: React.FC<Props> = ({
|
||||
|
||||
@@ -6,15 +6,34 @@ const messages = defineMessages({
|
||||
loading: { id: 'loading_indicator.label', defaultMessage: 'Loading…' },
|
||||
});
|
||||
|
||||
export const LoadingIndicator: React.FC = () => {
|
||||
interface LoadingIndicatorProps {
|
||||
/**
|
||||
* Use role='none' to opt out of the current default role 'progressbar'
|
||||
* and aria attributes which we should re-visit to check if they're appropriate.
|
||||
* In Firefox the aria-label is not applied, instead an implied value of `50` is
|
||||
* used as the label.
|
||||
*/
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
role = 'progressbar',
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const a11yProps =
|
||||
role === 'progressbar'
|
||||
? ({
|
||||
role,
|
||||
'aria-busy': true,
|
||||
'aria-live': 'polite',
|
||||
} as const)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='loading-indicator'
|
||||
role='progressbar'
|
||||
aria-busy
|
||||
aria-live='polite'
|
||||
{...a11yProps}
|
||||
aria-label={intl.formatMessage(messages.loading)}
|
||||
>
|
||||
<CircularProgress size={50} strokeWidth={6} />
|
||||
|
||||
@@ -48,7 +48,7 @@ export const MediaIcon: React.FC<{
|
||||
className={className}
|
||||
id={icon}
|
||||
icon={iconComponents[icon]}
|
||||
title={intl.formatMessage(messages[icon])}
|
||||
aria-label={intl.formatMessage(messages[icon])}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import Trends from 'flavours/glitch/features/getting_started/containers/trends_container';
|
||||
import { showTrends } from 'flavours/glitch/initial_state';
|
||||
|
||||
export const NavigationPortal: React.FC = () => (
|
||||
<div className='navigation-panel__portal'>{showTrends && <Trends />}</div>
|
||||
);
|
||||
@@ -14,7 +14,6 @@ import { fetchPoll, vote } from 'flavours/glitch/actions/polls';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import emojify from 'flavours/glitch/features/emoji/emoji';
|
||||
import { useIdentity } from 'flavours/glitch/identity_context';
|
||||
import { reduceMotion } from 'flavours/glitch/initial_state';
|
||||
import { makeEmojiMap } from 'flavours/glitch/models/custom_emoji';
|
||||
import type * as Model from 'flavours/glitch/models/poll';
|
||||
import type { Status } from 'flavours/glitch/models/status';
|
||||
@@ -265,7 +264,6 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
|
||||
to: {
|
||||
width: `${percent}%`,
|
||||
},
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -320,7 +318,7 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
|
||||
id='check'
|
||||
icon={CheckIcon}
|
||||
className='poll__voted__mark'
|
||||
title={intl.formatMessage(messages.voted)}
|
||||
aria-label={intl.formatMessage(messages.voted)}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -377,7 +377,11 @@ class Status extends ImmutablePureComponent {
|
||||
if (newTab) {
|
||||
window.open(path, '_blank', 'noopener');
|
||||
} else {
|
||||
history.push(path);
|
||||
if (history.location.pathname.replace('/deck/', '/') === path) {
|
||||
history.replace(path);
|
||||
} else {
|
||||
history.push(path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -735,10 +739,10 @@ class Status extends ImmutablePureComponent {
|
||||
{...statusContentProps}
|
||||
/>
|
||||
|
||||
{children}
|
||||
|
||||
{media}
|
||||
{hashtagBar}
|
||||
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { MouseEventHandler } from 'react';
|
||||
import { useCallback, useRef, useId } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export enum BannerVariant {
|
||||
@@ -5,38 +8,67 @@ export enum BannerVariant {
|
||||
Filter = 'filter',
|
||||
}
|
||||
|
||||
const stopPropagation: MouseEventHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
export const StatusBanner: React.FC<{
|
||||
children: React.ReactNode;
|
||||
variant: BannerVariant;
|
||||
expanded?: boolean;
|
||||
onClick?: () => void;
|
||||
}> = ({ children, variant, expanded, onClick }) => (
|
||||
<label
|
||||
className={
|
||||
variant === BannerVariant.Warning
|
||||
? 'content-warning'
|
||||
: 'content-warning content-warning--filter'
|
||||
}
|
||||
>
|
||||
{children}
|
||||
}> = ({ children, variant, expanded, onClick }) => {
|
||||
const descriptionId = useId();
|
||||
|
||||
<button className='link-button' onClick={onClick}>
|
||||
{expanded ? (
|
||||
<FormattedMessage
|
||||
id='content_warning.hide'
|
||||
defaultMessage='Hide post'
|
||||
/>
|
||||
) : variant === BannerVariant.Warning ? (
|
||||
<FormattedMessage
|
||||
id='content_warning.show_more'
|
||||
defaultMessage='Show more'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='content_warning.show'
|
||||
defaultMessage='Show anyway'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</label>
|
||||
);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const forwardClick = useCallback<MouseEventHandler>((e) => {
|
||||
if (
|
||||
buttonRef.current &&
|
||||
e.target !== buttonRef.current &&
|
||||
!buttonRef.current.contains(e.target as Node)
|
||||
) {
|
||||
buttonRef.current.click();
|
||||
buttonRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
// Element clicks are passed on to button
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={
|
||||
variant === BannerVariant.Warning
|
||||
? 'content-warning'
|
||||
: 'content-warning content-warning--filter'
|
||||
}
|
||||
onClick={forwardClick}
|
||||
onMouseUp={stopPropagation}
|
||||
>
|
||||
<p id={descriptionId}>{children}</p>
|
||||
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className='link-button'
|
||||
onClick={onClick}
|
||||
aria-describedby={descriptionId}
|
||||
>
|
||||
{expanded ? (
|
||||
<FormattedMessage
|
||||
id='content_warning.hide'
|
||||
defaultMessage='Hide post'
|
||||
/>
|
||||
) : variant === BannerVariant.Warning ? (
|
||||
<FormattedMessage
|
||||
id='content_warning.show_more'
|
||||
defaultMessage='Show more'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='content_warning.show'
|
||||
defaultMessage='Show anyway'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -349,7 +349,7 @@ class StatusContent extends PureComponent {
|
||||
if (this.props.onClick) {
|
||||
return (
|
||||
<>
|
||||
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} tabIndex={0} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
|
||||
|
||||
{poll}
|
||||
@@ -361,7 +361,7 @@ class StatusContent extends PureComponent {
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className={classNames} ref={this.setRef} tabIndex={0} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
|
||||
|
||||
{poll}
|
||||
|
||||
@@ -61,16 +61,14 @@ class StatusIcons extends PureComponent {
|
||||
className='status__reply-icon'
|
||||
id='comment'
|
||||
icon={ForumIcon}
|
||||
aria-hidden='true'
|
||||
title={intl.formatMessage(messages.inReplyTo)}
|
||||
aria-label={intl.formatMessage(messages.inReplyTo)}
|
||||
/>
|
||||
) : null}
|
||||
{settings.get('local_only') && status.get('local_only') &&
|
||||
<Icon
|
||||
id='home'
|
||||
icon={HomeIcon}
|
||||
aria-hidden='true'
|
||||
title={intl.formatMessage(messages.localOnly)}
|
||||
aria-label={intl.formatMessage(messages.localOnly)}
|
||||
/>}
|
||||
{settings.get('media') && !!mediaIcons && mediaIcons.map(icon => (<MediaIcon key={`media-icon--${icon}`} className='status__media-icon' icon={icon} />))}
|
||||
{settings.get('visibility') && <VisibilityIcon visibility={status.get('visibility')} />}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
@@ -11,7 +13,11 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import StatusContainer from 'flavours/glitch/containers/status_container';
|
||||
import type { Status } from 'flavours/glitch/models/status';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
import type { RootState } from 'flavours/glitch/store';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import { fetchStatus } from '../actions/statuses';
|
||||
import { makeGetStatus } from '../selectors';
|
||||
|
||||
const MAX_QUOTE_POSTS_NESTING_LEVEL = 1;
|
||||
|
||||
@@ -31,7 +37,7 @@ const QuoteWrapper: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const QuoteLink: React.FC<{
|
||||
const NestedQuoteLink: React.FC<{
|
||||
status: Status;
|
||||
}> = ({ status }) => {
|
||||
const accountId = status.get('account') as string;
|
||||
@@ -64,6 +70,10 @@ const QuoteLink: React.FC<{
|
||||
};
|
||||
|
||||
type QuoteMap = ImmutableMap<'state' | 'quoted_status', string | null>;
|
||||
type GetStatusSelector = (
|
||||
state: RootState,
|
||||
props: { id?: string | null; contextType?: string },
|
||||
) => Status | null;
|
||||
|
||||
export const QuotedStatus: React.FC<{
|
||||
quote: QuoteMap;
|
||||
@@ -71,35 +81,59 @@ export const QuotedStatus: React.FC<{
|
||||
variant?: 'full' | 'link';
|
||||
nestingLevel?: number;
|
||||
}> = ({ quote, contextType, nestingLevel = 1, variant = 'full' }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const quotedStatusId = quote.get('quoted_status');
|
||||
const state = quote.get('state');
|
||||
const quoteState = quote.get('state');
|
||||
const status = useAppSelector((state) =>
|
||||
quotedStatusId ? state.statuses.get(quotedStatusId) : undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!status && quotedStatusId) {
|
||||
dispatch(fetchStatus(quotedStatusId));
|
||||
}
|
||||
}, [status, quotedStatusId, dispatch]);
|
||||
|
||||
// In order to find out whether the quoted post should be completely hidden
|
||||
// due to a matching filter, we run it through the selector used by `status_container`.
|
||||
// If this returns null even though `status` exists, it's because it's filtered.
|
||||
const getStatus = useMemo(() => makeGetStatus(), []) as GetStatusSelector;
|
||||
const statusWithExtraData = useAppSelector((state) =>
|
||||
getStatus(state, { id: quotedStatusId, contextType }),
|
||||
);
|
||||
const isFilteredAndHidden = status && statusWithExtraData === null;
|
||||
|
||||
let quoteError: React.ReactNode = null;
|
||||
|
||||
if (state === 'deleted') {
|
||||
if (isFilteredAndHidden) {
|
||||
quoteError = (
|
||||
<FormattedMessage
|
||||
id='status.quote_error.filtered'
|
||||
defaultMessage='Hidden due to one of your filters'
|
||||
/>
|
||||
);
|
||||
} else if (quoteState === 'deleted') {
|
||||
quoteError = (
|
||||
<FormattedMessage
|
||||
id='status.quote_error.removed'
|
||||
defaultMessage='This post was removed by its author.'
|
||||
/>
|
||||
);
|
||||
} else if (state === 'unauthorized') {
|
||||
} else if (quoteState === 'unauthorized') {
|
||||
quoteError = (
|
||||
<FormattedMessage
|
||||
id='status.quote_error.unauthorized'
|
||||
defaultMessage='This post cannot be displayed as you are not authorized to view it.'
|
||||
/>
|
||||
);
|
||||
} else if (state === 'pending') {
|
||||
} else if (quoteState === 'pending') {
|
||||
quoteError = (
|
||||
<FormattedMessage
|
||||
id='status.quote_error.pending_approval'
|
||||
defaultMessage='This post is pending approval from the original author.'
|
||||
/>
|
||||
);
|
||||
} else if (state === 'rejected' || state === 'revoked') {
|
||||
} else if (quoteState === 'rejected' || quoteState === 'revoked') {
|
||||
quoteError = (
|
||||
<FormattedMessage
|
||||
id='status.quote_error.rejected'
|
||||
@@ -120,7 +154,7 @@ export const QuotedStatus: React.FC<{
|
||||
}
|
||||
|
||||
if (variant === 'link' && status) {
|
||||
return <QuoteLink status={status} />;
|
||||
return <NestedQuoteLink status={status} />;
|
||||
}
|
||||
|
||||
const childQuote = status?.get('quote') as QuoteMap | undefined;
|
||||
|
||||
@@ -58,7 +58,7 @@ export const VisibilityIcon: React.FC<{ visibility: StatusVisibility }> = ({
|
||||
<Icon
|
||||
id={visibilityIcon.icon}
|
||||
icon={visibilityIcon.iconComponent}
|
||||
title={visibilityIcon.text}
|
||||
aria-label={visibilityIcon.text}
|
||||
className={'status__visibility-icon'}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import initialState, { title as siteTitle } from 'flavours/glitch/initial_state'
|
||||
import { IntlProvider } from 'flavours/glitch/locales';
|
||||
import { store } from 'flavours/glitch/store';
|
||||
import { isProduction } from 'flavours/glitch/utils/environment';
|
||||
import { BodyScrollLock } from 'flavours/glitch/features/ui/components/body_scroll_lock';
|
||||
|
||||
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
|
||||
|
||||
@@ -63,6 +64,7 @@ export default class Mastodon extends PureComponent {
|
||||
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
|
||||
<Route path='/' component={UI} />
|
||||
</ScrollContext>
|
||||
<BodyScrollLock />
|
||||
</Router>
|
||||
|
||||
<Helmet defaultTitle={title} titleTemplate={`%s - ${title}`} />
|
||||
|
||||
@@ -14,7 +14,6 @@ import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
|
||||
import { Video } from 'flavours/glitch/features/video';
|
||||
import { IntlProvider } from 'flavours/glitch/locales';
|
||||
import { createPollFromServerJSON } from 'flavours/glitch/models/poll';
|
||||
import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar';
|
||||
|
||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
|
||||
|
||||
@@ -34,9 +33,6 @@ export default class MediaContainer extends PureComponent {
|
||||
};
|
||||
|
||||
handleOpenMedia = (media, index, lang) => {
|
||||
document.body.classList.add('with-modals--active');
|
||||
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
|
||||
|
||||
this.setState({ media, index, lang });
|
||||
};
|
||||
|
||||
@@ -45,16 +41,10 @@ export default class MediaContainer extends PureComponent {
|
||||
const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props'));
|
||||
const mediaList = fromJS(media);
|
||||
|
||||
document.body.classList.add('with-modals--active');
|
||||
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
|
||||
|
||||
this.setState({ media: mediaList, lang, options });
|
||||
};
|
||||
|
||||
handleCloseMedia = () => {
|
||||
document.body.classList.remove('with-modals--active');
|
||||
document.documentElement.style.marginRight = '0';
|
||||
|
||||
this.setState({
|
||||
media: null,
|
||||
index: null,
|
||||
|
||||
@@ -29,7 +29,7 @@ interface BaseRule {
|
||||
|
||||
interface Rule extends BaseRule {
|
||||
id: string;
|
||||
translations: Record<string, BaseRule>;
|
||||
translations?: Record<string, BaseRule>;
|
||||
}
|
||||
|
||||
export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
|
||||
@@ -113,15 +113,23 @@ const rulesSelector = createSelector(
|
||||
(rules, locale): Rule[] => {
|
||||
return rules.map((rule) => {
|
||||
const translations = rule.translations;
|
||||
if (translations[locale]) {
|
||||
rule.text = translations[locale].text;
|
||||
rule.hint = translations[locale].hint;
|
||||
|
||||
// Handle cached responses from earlier versions
|
||||
if (!translations) {
|
||||
return rule;
|
||||
}
|
||||
|
||||
const partialLocale = locale.split('-')[0];
|
||||
if (partialLocale && translations[partialLocale]) {
|
||||
rule.text = translations[partialLocale].text;
|
||||
rule.hint = translations[partialLocale].hint;
|
||||
}
|
||||
|
||||
if (translations[locale]) {
|
||||
rule.text = translations[locale].text;
|
||||
rule.hint = translations[locale].hint;
|
||||
}
|
||||
|
||||
return rule;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { is } from 'immutable';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
|
||||
});
|
||||
|
||||
class InlineAlert extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
show: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
mountMessage: false,
|
||||
};
|
||||
|
||||
static TRANSITION_DELAY = 200;
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (!this.props.show && nextProps.show) {
|
||||
this.setState({ mountMessage: true });
|
||||
} else if (this.props.show && !nextProps.show) {
|
||||
setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { show } = this.props;
|
||||
const { mountMessage } = this.state;
|
||||
|
||||
return (
|
||||
<span aria-live='polite' role='status' className='inline-alert' style={{ opacity: show ? 1 : 0 }}>
|
||||
{mountMessage && <FormattedMessage id='generic.saved' defaultMessage='Saved' />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class AccountNote extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
accountId: PropTypes.string.isRequired,
|
||||
value: PropTypes.string,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
value: null,
|
||||
saving: false,
|
||||
saved: false,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount () {
|
||||
this._reset();
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
const accountWillChange = !is(this.props.accountId, nextProps.accountId);
|
||||
const newState = {};
|
||||
|
||||
if (accountWillChange && this._isDirty()) {
|
||||
this._save(false);
|
||||
}
|
||||
|
||||
if (accountWillChange || nextProps.value === this.state.value) {
|
||||
newState.saving = false;
|
||||
}
|
||||
|
||||
if (this.props.value !== nextProps.value) {
|
||||
newState.value = nextProps.value;
|
||||
}
|
||||
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this._isDirty()) {
|
||||
this._save(false);
|
||||
}
|
||||
}
|
||||
|
||||
setTextareaRef = c => {
|
||||
this.textarea = c;
|
||||
};
|
||||
|
||||
handleChange = e => {
|
||||
this.setState({ value: e.target.value, saving: false });
|
||||
};
|
||||
|
||||
handleKeyDown = e => {
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.textarea) {
|
||||
this.textarea.blur();
|
||||
} else {
|
||||
this._save();
|
||||
}
|
||||
} else if (e.keyCode === 27) {
|
||||
e.preventDefault();
|
||||
|
||||
this._reset(() => {
|
||||
if (this.textarea) {
|
||||
this.textarea.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
if (this._isDirty()) {
|
||||
this._save();
|
||||
}
|
||||
};
|
||||
|
||||
_save (showMessage = true) {
|
||||
this.setState({ saving: true }, () => this.props.onSave(this.state.value));
|
||||
|
||||
if (showMessage) {
|
||||
this.setState({ saved: true }, () => setTimeout(() => this.setState({ saved: false }), 2000));
|
||||
}
|
||||
}
|
||||
|
||||
_reset (callback) {
|
||||
this.setState({ value: this.props.value }, callback);
|
||||
}
|
||||
|
||||
_isDirty () {
|
||||
return !this.state.saving && this.props.value !== null && this.state.value !== null && this.state.value !== this.props.value;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { accountId, intl } = this.props;
|
||||
const { value, saved } = this.state;
|
||||
|
||||
if (!accountId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account__header__account-note'>
|
||||
<label htmlFor={`account-note-${accountId}`}>
|
||||
<FormattedMessage id='account.account_note_header' defaultMessage='Personal note' /> <InlineAlert show={saved} />
|
||||
</label>
|
||||
|
||||
<Textarea
|
||||
id={`account-note-${accountId}`}
|
||||
className='account__header__account-note__content'
|
||||
disabled={this.props.value === null || value === null}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={value || ''}
|
||||
onChange={this.handleChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onBlur={this.handleBlur}
|
||||
ref={this.setTextareaRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(AccountNote);
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
|
||||
import { useState, useRef, useCallback, useId } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
import { submitAccountNote } from '@/flavours/glitch/actions/account_notes';
|
||||
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: {
|
||||
id: 'account_note.placeholder',
|
||||
defaultMessage: 'Click to add a note',
|
||||
},
|
||||
});
|
||||
|
||||
const AccountNoteUI: React.FC<{
|
||||
initialValue: string | undefined;
|
||||
onSubmit: (newNote: string) => void;
|
||||
wasSaved: boolean;
|
||||
}> = ({ initialValue, onSubmit, wasSaved }) => {
|
||||
const intl = useIntl();
|
||||
const uniqueId = useId();
|
||||
const [value, setValue] = useState(initialValue ?? '');
|
||||
const isLoading = initialValue === undefined;
|
||||
const canSubmitOnBlurRef = useRef(true);
|
||||
|
||||
const handleChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>(
|
||||
(e) => {
|
||||
setValue(e.target.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
|
||||
(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
|
||||
setValue(initialValue ?? '');
|
||||
|
||||
canSubmitOnBlurRef.current = false;
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
onSubmit(value);
|
||||
|
||||
canSubmitOnBlurRef.current = false;
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
},
|
||||
[initialValue, onSubmit, value],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (initialValue !== value && canSubmitOnBlurRef.current) {
|
||||
onSubmit(value);
|
||||
}
|
||||
canSubmitOnBlurRef.current = true;
|
||||
}, [initialValue, onSubmit, value]);
|
||||
|
||||
return (
|
||||
<div className='account__header__account-note'>
|
||||
<label htmlFor={`account-note-${uniqueId}`}>
|
||||
<FormattedMessage
|
||||
id='account.account_note_header'
|
||||
defaultMessage='Personal note'
|
||||
/>{' '}
|
||||
<span
|
||||
aria-live='polite'
|
||||
role='status'
|
||||
className='inline-alert'
|
||||
style={{ opacity: wasSaved ? 1 : 0 }}
|
||||
>
|
||||
{wasSaved && (
|
||||
<FormattedMessage id='generic.saved' defaultMessage='Saved' />
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
{isLoading ? (
|
||||
<div className='account__header__account-note__loading-indicator-wrapper'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
id={`account-note-${uniqueId}`}
|
||||
className='account__header__account-note__content'
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountNote: React.FC<{
|
||||
accountId: string;
|
||||
}> = ({ accountId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const initialValue = useAppSelector((state) =>
|
||||
state.relationships.get(accountId)?.get('note'),
|
||||
);
|
||||
const [wasSaved, setWasSaved] = useState(false);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(note: string) => {
|
||||
setWasSaved(true);
|
||||
void dispatch(submitAccountNote({ accountId, note }));
|
||||
|
||||
setTimeout(() => {
|
||||
setWasSaved(false);
|
||||
}, 2000);
|
||||
},
|
||||
[dispatch, accountId],
|
||||
);
|
||||
|
||||
return (
|
||||
<AccountNoteUI
|
||||
key={`${accountId}-${initialValue}`}
|
||||
initialValue={initialValue}
|
||||
wasSaved={wasSaved}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { submitAccountNote } from 'flavours/glitch/actions/account_notes';
|
||||
|
||||
import AccountNote from '../components/account_note';
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
value: state.relationships.getIn([accountId, 'note']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { accountId }) => ({
|
||||
|
||||
onSave (value) {
|
||||
dispatch(submitAccountNote({ accountId: accountId, note: value }));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
|
||||
@@ -42,8 +42,8 @@ import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||
import { FormattedDateWrapper } from 'flavours/glitch/components/formatted_date';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
import { AccountNote } from 'flavours/glitch/features/account/components/account_note';
|
||||
import { DomainPill } from 'flavours/glitch/features/account/components/domain_pill';
|
||||
import AccountNoteContainer from 'flavours/glitch/features/account/containers/account_note_container';
|
||||
import FollowRequestNoteContainer from 'flavours/glitch/features/account/containers/follow_request_note_container';
|
||||
import { useLinks } from 'flavours/glitch/hooks/useLinks';
|
||||
import { useIdentity } from 'flavours/glitch/identity_context';
|
||||
@@ -422,7 +422,7 @@ export const AccountHeader: React.FC<{
|
||||
return arr;
|
||||
}
|
||||
|
||||
if (signedIn && account.id !== me && !account.suspended) {
|
||||
if (signedIn && !account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mention, {
|
||||
name: account.username,
|
||||
@@ -446,37 +446,7 @@ export const AccountHeader: React.FC<{
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (account.id === me) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.edit_profile),
|
||||
href: '/settings/profile',
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.preferences),
|
||||
href: '/settings/preferences',
|
||||
});
|
||||
arr.push(null);
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.follow_requests),
|
||||
to: '/follow_requests',
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.favourites),
|
||||
to: '/favourites',
|
||||
});
|
||||
arr.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.followed_tags),
|
||||
to: '/followed_tags',
|
||||
});
|
||||
arr.push(null);
|
||||
arr.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
||||
arr.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.domain_blocks),
|
||||
to: '/domain_blocks',
|
||||
});
|
||||
} else if (signedIn) {
|
||||
if (signedIn) {
|
||||
if (relationship?.following) {
|
||||
if (!relationship.muting) {
|
||||
if (relationship.showing_reblogs) {
|
||||
@@ -615,8 +585,7 @@ export const AccountHeader: React.FC<{
|
||||
}
|
||||
|
||||
if (
|
||||
(account.id !== me &&
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) ||
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
|
||||
(isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION)
|
||||
@@ -803,7 +772,7 @@ export const AccountHeader: React.FC<{
|
||||
<Icon
|
||||
id='lock'
|
||||
icon={LockIcon}
|
||||
title={intl.formatMessage(messages.account_locked)}
|
||||
aria-label={intl.formatMessage(messages.account_locked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -884,12 +853,14 @@ export const AccountHeader: React.FC<{
|
||||
<div className='account__header__tabs__buttons'>
|
||||
{!hidden && bellBtn}
|
||||
{!hidden && shareBtn}
|
||||
<Dropdown
|
||||
disabled={menu.length === 0}
|
||||
items={menu}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
/>
|
||||
{accountId !== me && (
|
||||
<Dropdown
|
||||
disabled={menu.length === 0}
|
||||
items={menu}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
/>
|
||||
)}
|
||||
{!hidden && actionBtn}
|
||||
</div>
|
||||
</div>
|
||||
@@ -927,7 +898,7 @@ export const AccountHeader: React.FC<{
|
||||
onClickCapture={handleLinkClick}
|
||||
>
|
||||
{account.id !== me && signedIn && (
|
||||
<AccountNoteContainer accountId={accountId} />
|
||||
<AccountNote accountId={accountId} />
|
||||
)}
|
||||
|
||||
{account.note.length > 0 && account.note !== '<p></p>' && (
|
||||
|
||||
@@ -138,7 +138,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
render () {
|
||||
const { accountId, statusIds, isLoading, hasMore, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
|
||||
const { accountId, statusIds, isLoading, hasMore, suspended, isAccount, hidden, multiColumn, remote, remoteUrl, params: { tagged } } = this.props;
|
||||
|
||||
if (isLoading && statusIds.isEmpty()) {
|
||||
return (
|
||||
@@ -173,8 +173,8 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||
<StatusList
|
||||
prepend={
|
||||
<>
|
||||
<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />
|
||||
{!forceEmptyState && <FeaturedCarousel accountId={this.props.accountId} />}
|
||||
<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={tagged} />
|
||||
{!forceEmptyState && <FeaturedCarousel accountId={this.props.accountId} tagged={tagged} />}
|
||||
</>
|
||||
}
|
||||
alwaysPrepend
|
||||
|
||||
@@ -27,7 +27,7 @@ import { Audio } from 'flavours/glitch/features/audio';
|
||||
import { CharacterCounter } from 'flavours/glitch/features/compose/components/character_counter';
|
||||
import { Tesseract as fetchTesseract } from 'flavours/glitch/features/ui/util/async-components';
|
||||
import { Video, getPointerPosition } from 'flavours/glitch/features/video';
|
||||
import { me, reduceMotion } from 'flavours/glitch/initial_state';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
import { assetHost } from 'flavours/glitch/utils/config';
|
||||
@@ -110,7 +110,7 @@ const Preview: React.FC<{
|
||||
left: `${x * 100}%`,
|
||||
top: `${y * 100}%`,
|
||||
},
|
||||
immediate: reduceMotion || draggingRef.current,
|
||||
immediate: draggingRef.current,
|
||||
});
|
||||
const media = useAppSelector((state) =>
|
||||
(
|
||||
|
||||
@@ -17,12 +17,9 @@ import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { SpoilerButton } from 'flavours/glitch/components/spoiler_button';
|
||||
import { formatTime, getPointerPosition } from 'flavours/glitch/features/video';
|
||||
import { useAudioContext } from 'flavours/glitch/hooks/useAudioContext';
|
||||
import { useAudioVisualizer } from 'flavours/glitch/hooks/useAudioVisualizer';
|
||||
import {
|
||||
displayMedia,
|
||||
useBlurhash,
|
||||
reduceMotion,
|
||||
} from 'flavours/glitch/initial_state';
|
||||
import { displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
|
||||
import { playerSettings } from 'flavours/glitch/settings';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -119,12 +116,17 @@ export const Audio: React.FC<{
|
||||
const seekRef = useRef<HTMLDivElement>(null);
|
||||
const volumeRef = useRef<HTMLDivElement>(null);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
|
||||
const [resumeAudio, suspendAudio, frequencyBands] = useAudioVisualizer(
|
||||
audioRef,
|
||||
3,
|
||||
);
|
||||
const accessibilityId = useId();
|
||||
|
||||
const { audioContextRef, sourceRef, gainNodeRef, playAudio, pauseAudio } =
|
||||
useAudioContext({ audioElementRef: audioRef });
|
||||
|
||||
const frequencyBands = useAudioVisualizer({
|
||||
audioContextRef,
|
||||
sourceRef,
|
||||
numBands: 3,
|
||||
});
|
||||
|
||||
const [style, spring] = useSpring(() => ({
|
||||
progress: '0%',
|
||||
buffer: '0%',
|
||||
@@ -152,22 +154,23 @@ export const Audio: React.FC<{
|
||||
restoreVolume(audioRef.current);
|
||||
setVolume(audioRef.current.volume);
|
||||
setMuted(audioRef.current.muted);
|
||||
if (gainNodeRef.current) {
|
||||
gainNodeRef.current.gain.value = audioRef.current.volume;
|
||||
}
|
||||
void spring.start({
|
||||
volume: `${audioRef.current.volume * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
spring,
|
||||
setVolume,
|
||||
setMuted,
|
||||
deployPictureInPicture,
|
||||
src,
|
||||
poster,
|
||||
backgroundColor,
|
||||
accentColor,
|
||||
foregroundColor,
|
||||
deployPictureInPicture,
|
||||
accentColor,
|
||||
gainNodeRef,
|
||||
spring,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -178,7 +181,11 @@ export const Audio: React.FC<{
|
||||
|
||||
audioRef.current.volume = volume;
|
||||
audioRef.current.muted = muted;
|
||||
}, [volume, muted]);
|
||||
|
||||
if (gainNodeRef.current) {
|
||||
gainNodeRef.current.gain.value = muted ? 0 : volume;
|
||||
}
|
||||
}, [volume, muted, gainNodeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof visible !== 'undefined') {
|
||||
@@ -192,11 +199,10 @@ export const Audio: React.FC<{
|
||||
}, [visible, sensitive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealed && audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
suspendAudio();
|
||||
if (!revealed) {
|
||||
pauseAudio();
|
||||
}
|
||||
}, [suspendAudio, revealed]);
|
||||
}, [pauseAudio, revealed]);
|
||||
|
||||
useEffect(() => {
|
||||
let nextFrame: ReturnType<typeof requestAnimationFrame>;
|
||||
@@ -206,7 +212,6 @@ export const Audio: React.FC<{
|
||||
if (audioRef.current && audioRef.current.duration > 0) {
|
||||
void spring.start({
|
||||
progress: `${(audioRef.current.currentTime / audioRef.current.duration) * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
config: config.stiff,
|
||||
});
|
||||
}
|
||||
@@ -228,13 +233,11 @@ export const Audio: React.FC<{
|
||||
}
|
||||
|
||||
if (audioRef.current.paused) {
|
||||
resumeAudio();
|
||||
void audioRef.current.play();
|
||||
playAudio();
|
||||
} else {
|
||||
audioRef.current.pause();
|
||||
suspendAudio();
|
||||
pauseAudio();
|
||||
}
|
||||
}, [resumeAudio, suspendAudio]);
|
||||
}, [playAudio, pauseAudio]);
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
setPaused(false);
|
||||
@@ -254,7 +257,6 @@ export const Audio: React.FC<{
|
||||
if (lastTimeRange > -1) {
|
||||
void spring.start({
|
||||
buffer: `${Math.ceil(audioRef.current.buffered.end(lastTimeRange) / audioRef.current.duration) * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
}
|
||||
}, [spring]);
|
||||
@@ -269,7 +271,6 @@ export const Audio: React.FC<{
|
||||
|
||||
void spring.start({
|
||||
volume: `${audioRef.current.muted ? 0 : audioRef.current.volume * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
|
||||
persistVolume(audioRef.current.volume, audioRef.current.muted);
|
||||
@@ -349,8 +350,7 @@ export const Audio: React.FC<{
|
||||
document.removeEventListener('mouseup', handleSeekMouseUp, true);
|
||||
|
||||
setDragging(false);
|
||||
resumeAudio();
|
||||
void audioRef.current?.play();
|
||||
playAudio();
|
||||
};
|
||||
|
||||
const handleSeekMouseMove = (e: MouseEvent) => {
|
||||
@@ -377,7 +377,7 @@ export const Audio: React.FC<{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[setDragging, spring, resumeAudio],
|
||||
[playAudio, spring],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
@@ -446,10 +446,9 @@ export const Audio: React.FC<{
|
||||
|
||||
const handleCanPlayThrough = useCallback(() => {
|
||||
if (startPlaying) {
|
||||
resumeAudio();
|
||||
void audioRef.current?.play();
|
||||
playAudio();
|
||||
}
|
||||
}, [startPlaying, resumeAudio]);
|
||||
}, [startPlaying, playAudio]);
|
||||
|
||||
const seekBy = (time: number) => {
|
||||
if (!audioRef.current) {
|
||||
@@ -492,7 +491,7 @@ export const Audio: React.FC<{
|
||||
return;
|
||||
}
|
||||
|
||||
const newVolume = audioRef.current.volume + step;
|
||||
const newVolume = Math.max(0, audioRef.current.volume + step);
|
||||
|
||||
if (!isNaN(newVolume)) {
|
||||
audioRef.current.volume = newVolume;
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
||||
import { useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
preferences: {
|
||||
id: 'navigation_bar.preferences',
|
||||
defaultMessage: 'Preferences',
|
||||
},
|
||||
follow_requests: {
|
||||
id: 'navigation_bar.follow_requests',
|
||||
defaultMessage: 'Follow requests',
|
||||
},
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
followed_tags: {
|
||||
id: 'navigation_bar.followed_tags',
|
||||
defaultMessage: 'Followed hashtags',
|
||||
},
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: {
|
||||
id: 'navigation_bar.domain_blocks',
|
||||
defaultMessage: 'Blocked domains',
|
||||
},
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
||||
export const ActionBar: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const handleLogoutClick = () => {
|
||||
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT', modalProps: {} }));
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
text: intl.formatMessage(messages.edit_profile),
|
||||
href: '/settings/profile',
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.preferences),
|
||||
href: '/settings/preferences',
|
||||
},
|
||||
null,
|
||||
{
|
||||
text: intl.formatMessage(messages.follow_requests),
|
||||
to: '/follow_requests',
|
||||
},
|
||||
{ text: intl.formatMessage(messages.favourites), to: '/favourites' },
|
||||
{ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' },
|
||||
{ text: intl.formatMessage(messages.lists), to: '/lists' },
|
||||
{
|
||||
text: intl.formatMessage(messages.followed_tags),
|
||||
to: '/followed_tags',
|
||||
},
|
||||
null,
|
||||
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
|
||||
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
|
||||
{
|
||||
text: intl.formatMessage(messages.domain_blocks),
|
||||
to: '/domain_blocks',
|
||||
},
|
||||
{ text: intl.formatMessage(messages.filters), href: '/filters' },
|
||||
null,
|
||||
{ text: intl.formatMessage(messages.logout), action: handleLogoutClick },
|
||||
];
|
||||
}, [intl, dispatch]);
|
||||
|
||||
return <Dropdown items={menu} icon='bars' iconComponent={MoreHorizIcon} />;
|
||||
};
|
||||
@@ -12,9 +12,10 @@ import { length } from 'stringz';
|
||||
|
||||
import { missingAltTextModal } from 'flavours/glitch/initial_state';
|
||||
|
||||
import AutosuggestInput from '../../../components/autosuggest_input';
|
||||
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
||||
import { Button } from '../../../components/button';
|
||||
import AutosuggestInput from 'flavours/glitch/components/autosuggest_input';
|
||||
import AutosuggestTextarea from 'flavours/glitch/components/autosuggest_textarea';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
||||
import PollButtonContainer from '../containers/poll_button_container';
|
||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||
@@ -242,9 +243,8 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, onPaste, autoFocus, withoutNavigation, maxChars } = this.props;
|
||||
const { intl, onPaste, autoFocus, withoutNavigation, maxChars, isSubmitting } = this.props;
|
||||
const { highlighted } = this.state;
|
||||
const disabled = this.props.isSubmitting;
|
||||
|
||||
return (
|
||||
<form className='compose-form' onSubmit={this.handleSubmit}>
|
||||
@@ -263,7 +263,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
<AutosuggestInput
|
||||
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
||||
value={this.props.spoilerText}
|
||||
disabled={disabled}
|
||||
disabled={isSubmitting}
|
||||
onChange={this.handleChangeSpoilerText}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
ref={this.setSpoilerText}
|
||||
@@ -285,7 +285,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
<AutosuggestTextarea
|
||||
ref={this.textareaRef}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
disabled={disabled}
|
||||
disabled={isSubmitting}
|
||||
value={this.props.text}
|
||||
onChange={this.handleChange}
|
||||
suggestions={this.props.suggestions}
|
||||
@@ -331,9 +331,15 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
<Button
|
||||
type='submit'
|
||||
compact
|
||||
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
|
||||
disabled={!this.canSubmit()}
|
||||
/>
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{intl.formatMessage(
|
||||
this.props.isEditing ?
|
||||
messages.saveChanges :
|
||||
(this.props.isInReply ? messages.reply : messages.publish)
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,34 +2,44 @@ import { useCallback } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
|
||||
import { Account } from 'flavours/glitch/components/account';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
|
||||
import { ActionBar } from './action_bar';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
|
||||
});
|
||||
|
||||
export const NavigationBar = () => {
|
||||
const dispatch = useDispatch();
|
||||
export const NavigationBar: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to']));
|
||||
const isReplying = useAppSelector(
|
||||
(state) => !!state.compose.get('in_reply_to'),
|
||||
);
|
||||
|
||||
const handleCancelClick = useCallback(() => {
|
||||
dispatch(cancelReplyCompose());
|
||||
}, [dispatch]);
|
||||
|
||||
if (!me) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='navigation-bar'>
|
||||
<Account id={me} minimal />
|
||||
{isReplying ? <IconButton title={intl.formatMessage(messages.cancel)} iconComponent={CloseIcon} onClick={handleCancelClick} /> : <ActionBar />}
|
||||
|
||||
{isReplying && (
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.cancel)}
|
||||
icon=''
|
||||
iconComponent={CloseIcon}
|
||||
onClick={handleCancelClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user