mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-14 08:19:05 +00:00
Compare commits
371 Commits
v4.3.0-bet
...
v4.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2d65f7142 | ||
|
|
13ab4b54e2 | ||
|
|
df0b641914 | ||
|
|
624b942c2e | ||
|
|
cfa2e0503a | ||
|
|
de945eef63 | ||
|
|
9a7030fb69 | ||
|
|
221da1ba04 | ||
|
|
ff85540904 | ||
|
|
c2862049a2 | ||
|
|
0d69cc068c | ||
|
|
26f25ef4ba | ||
|
|
3b4070cfcc | ||
|
|
eb997c9f0e | ||
|
|
4239baa1f4 | ||
|
|
5532d1c2cb | ||
|
|
3f0d90f019 | ||
|
|
15e1a63e4a | ||
|
|
6b8ff1cf6e | ||
|
|
6cbd217055 | ||
|
|
90c7c1bf7d | ||
|
|
e06448e652 | ||
|
|
3752db3c9a | ||
|
|
cc5c125cc7 | ||
|
|
f65523c5b6 | ||
|
|
5b6b23eeef | ||
|
|
0cbf03efa7 | ||
|
|
90f2c7a1e9 | ||
|
|
f0d734cc6e | ||
|
|
0720ef5f62 | ||
|
|
dc9a106d4c | ||
|
|
c634da32cf | ||
|
|
2d8ce9e19a | ||
|
|
1ddf1aedf1 | ||
|
|
931870ca34 | ||
|
|
7f9b0f36ba | ||
|
|
dd0992b25d | ||
|
|
9b677f099e | ||
|
|
c13b8026f0 | ||
|
|
bf1375ae37 | ||
|
|
b06161dba3 | ||
|
|
a089109b77 | ||
|
|
74f9f7c600 | ||
|
|
ea1b598246 | ||
|
|
dbedd021f5 | ||
|
|
5d79af928c | ||
|
|
a62be22cb1 | ||
|
|
96ffbc05c0 | ||
|
|
39fb314421 | ||
|
|
90f6984ff1 | ||
|
|
9adb96f3a1 | ||
|
|
d5a3478864 | ||
|
|
9877a053f6 | ||
|
|
605ed50603 | ||
|
|
e1609c6813 | ||
|
|
de5d6e98ae | ||
|
|
2b0410f903 | ||
|
|
f7aab0cc2f | ||
|
|
de5f522cc0 | ||
|
|
d728fa9991 | ||
|
|
044dd3f788 | ||
|
|
afc440435c | ||
|
|
d0fb7939bb | ||
|
|
7388a6ce9a | ||
|
|
cd2a3bac79 | ||
|
|
f0e011fbc9 | ||
|
|
acbc273d6e | ||
|
|
1f0c84749d | ||
|
|
e507b4f884 | ||
|
|
93348136a5 | ||
|
|
3a5e83b91a | ||
|
|
8d37565c19 | ||
|
|
177e8fe972 | ||
|
|
198283a188 | ||
|
|
36452845d7 | ||
|
|
5c4bcd2f08 | ||
|
|
a20f38c930 | ||
|
|
b01bd74698 | ||
|
|
41e342a88f | ||
|
|
9258ee8847 | ||
|
|
6d72c13a4d | ||
|
|
ad4be12473 | ||
|
|
527d1253bf | ||
|
|
ae676edc2b | ||
|
|
63df649fe5 | ||
|
|
0ff427fab3 | ||
|
|
dc2f9eef77 | ||
|
|
ff1247ad16 | ||
|
|
fbe55a4545 | ||
|
|
a72819660a | ||
|
|
c292ed07fe | ||
|
|
2d008108a4 | ||
|
|
0c59ef44b1 | ||
|
|
12297faa1d | ||
|
|
9b6f92e47f | ||
|
|
8b6247ca44 | ||
|
|
836cbca469 | ||
|
|
b091e531a5 | ||
|
|
49b3d5692e | ||
|
|
70472de726 | ||
|
|
304e440f88 | ||
|
|
ca68a3cacb | ||
|
|
066efc2d3f | ||
|
|
a2e24ee2de | ||
|
|
ee61f7772a | ||
|
|
5ee72f0e2d | ||
|
|
192e9d16eb | ||
|
|
a3f40309fb | ||
|
|
782a785893 | ||
|
|
9f165436d2 | ||
|
|
592945e498 | ||
|
|
bfb610922d | ||
|
|
480dcecc11 | ||
|
|
2647606a15 | ||
|
|
b40adb4a89 | ||
|
|
f99da81ef8 | ||
|
|
799f507dce | ||
|
|
81472396bc | ||
|
|
a295832960 | ||
|
|
e018e6321f | ||
|
|
f75eb1a8b0 | ||
|
|
de4f7859b4 | ||
|
|
e5e0144957 | ||
|
|
45a520603b | ||
|
|
6ac78ead52 | ||
|
|
c0d3b3de10 | ||
|
|
9e04e46521 | ||
|
|
fa4a82326d | ||
|
|
93fa102f9a | ||
|
|
9ee86a738e | ||
|
|
e272cf5983 | ||
|
|
4382de310c | ||
|
|
94c69bba25 | ||
|
|
ab36c152f9 | ||
|
|
fc5b558b32 | ||
|
|
77ff94d3d2 | ||
|
|
959841ae95 | ||
|
|
f669493d96 | ||
|
|
83b3c50778 | ||
|
|
dc7a42551f | ||
|
|
4a859140ec | ||
|
|
edcf3d9234 | ||
|
|
cae93e79a4 | ||
|
|
83a98cb81a | ||
|
|
889edc560a | ||
|
|
2e0d918d7d | ||
|
|
3b4312476f | ||
|
|
4fba4f8c82 | ||
|
|
25de2f57ee | ||
|
|
026643ab24 | ||
|
|
61e3e81e28 | ||
|
|
354f54907d | ||
|
|
4d611e94ee | ||
|
|
a09a26da49 | ||
|
|
59a8066045 | ||
|
|
3cad5095c9 | ||
|
|
e58d99a771 | ||
|
|
69c76fd94a | ||
|
|
1b6bd585ab | ||
|
|
dfe851b476 | ||
|
|
6d5aa58f88 | ||
|
|
81cd489208 | ||
|
|
55b5364534 | ||
|
|
2e8b752c55 | ||
|
|
d82ffdccbb | ||
|
|
5c72b46a4e | ||
|
|
aa46348c03 | ||
|
|
404f467fcf | ||
|
|
4a2d3929c5 | ||
|
|
ceba0f082e | ||
|
|
7de8d5ffca | ||
|
|
74291dfb77 | ||
|
|
f07707a9bb | ||
|
|
931553844d | ||
|
|
243a85ec8d | ||
|
|
cbf1349370 | ||
|
|
b8fdffe824 | ||
|
|
c91e06bcad | ||
|
|
b2ce9bb4c7 | ||
|
|
19d1392b33 | ||
|
|
09cf617d7f | ||
|
|
784d1bfb29 | ||
|
|
754b03d8cb | ||
|
|
f397550311 | ||
|
|
97db4bd4dd | ||
|
|
1e19242134 | ||
|
|
4e6f13a0fb | ||
|
|
f517f0dbef | ||
|
|
53624b1b54 | ||
|
|
a473988969 | ||
|
|
4ad1e955eb | ||
|
|
66ef4b9984 | ||
|
|
ce2481a81b | ||
|
|
efa74a6c44 | ||
|
|
bdceb1dacf | ||
|
|
e13453aec4 | ||
|
|
25e8a6eaeb | ||
|
|
c828e7731c | ||
|
|
6734b6550f | ||
|
|
6398d7b784 | ||
|
|
1283c3544c | ||
|
|
8ac00533ff | ||
|
|
1b3472bec8 | ||
|
|
c8df7f4995 | ||
|
|
94743fea2c | ||
|
|
deee164acf | ||
|
|
88756ab75f | ||
|
|
9af9ef6fb3 | ||
|
|
03210085b7 | ||
|
|
0c872beed4 | ||
|
|
e22eff8900 | ||
|
|
431b382563 | ||
|
|
bf7cfba48e | ||
|
|
f477dc399e | ||
|
|
6037714f76 | ||
|
|
c352ce6f45 | ||
|
|
9bf624b44d | ||
|
|
e80971e660 | ||
|
|
9e10fd59b7 | ||
|
|
9b5f073cb3 | ||
|
|
157ecf255b | ||
|
|
b8c23f94b0 | ||
|
|
3fa34bd73a | ||
|
|
77d2f7eef6 | ||
|
|
8439084587 | ||
|
|
7b290cee47 | ||
|
|
7ef25ae53b | ||
|
|
9d664f87a0 | ||
|
|
24d3ce7bab | ||
|
|
11a12e56b3 | ||
|
|
cdd7526531 | ||
|
|
e02e88bff4 | ||
|
|
04dd3a9eb6 | ||
|
|
675ec1a0ad | ||
|
|
c9b0699964 | ||
|
|
513f187daf | ||
|
|
ee2d966080 | ||
|
|
106b22bd2d | ||
|
|
89c39e7826 | ||
|
|
a04433f995 | ||
|
|
7a62d57427 | ||
|
|
89df27a06c | ||
|
|
00aaf77e04 | ||
|
|
437cecc965 | ||
|
|
db57fe80c8 | ||
|
|
278a075b22 | ||
|
|
886baa5e35 | ||
|
|
db332553c9 | ||
|
|
f610fdd6e7 | ||
|
|
3426ea2912 | ||
|
|
28c4eca0af | ||
|
|
0b1310feb3 | ||
|
|
5716ebf390 | ||
|
|
36ce5813cb | ||
|
|
d9d84822bb | ||
|
|
633165ba9c | ||
|
|
28966fa0a6 | ||
|
|
739ad0eed2 | ||
|
|
51777fe3e2 | ||
|
|
d6f5ee75ab | ||
|
|
3dc4ddc663 | ||
|
|
83574f641a | ||
|
|
c2ef83ea4c | ||
|
|
c3b6a7a297 | ||
|
|
06ecf9008b | ||
|
|
69aa5699ce | ||
|
|
4e6fc3a62f | ||
|
|
a773c239c3 | ||
|
|
440b695b79 | ||
|
|
5df7e36244 | ||
|
|
ba7b1f06c1 | ||
|
|
6142adc7d6 | ||
|
|
131696277c | ||
|
|
aac6296183 | ||
|
|
c6039f99ce | ||
|
|
70988519df | ||
|
|
f1b6a611aa | ||
|
|
556837f156 | ||
|
|
c36a76b9eb | ||
|
|
0a6b75b71e | ||
|
|
cfb8fc6222 | ||
|
|
19dedd7cfd | ||
|
|
780e2e9d66 | ||
|
|
7c61533111 | ||
|
|
11ac5c8929 | ||
|
|
c808055fc3 | ||
|
|
d54ce67dc9 | ||
|
|
6551129aff | ||
|
|
38744a4e51 | ||
|
|
c2c3a66478 | ||
|
|
5dfdec6453 | ||
|
|
aaab6b7adc | ||
|
|
b5bdc69f7b | ||
|
|
bbf7752256 | ||
|
|
2b4bda8004 | ||
|
|
447d0a3e88 | ||
|
|
66ed7ea4b5 | ||
|
|
cd7b670cd8 | ||
|
|
5d6a3f2cb0 | ||
|
|
770ec9240a | ||
|
|
11eae691ba | ||
|
|
ed90d9342e | ||
|
|
0ba3ad4a35 | ||
|
|
e0b45b35c9 | ||
|
|
5fae1d55e5 | ||
|
|
10d2f83025 | ||
|
|
958f01e722 | ||
|
|
f4632d941a | ||
|
|
c37f9c0d44 | ||
|
|
84d04386dd | ||
|
|
f294c4a594 | ||
|
|
efc0d4d526 | ||
|
|
6ac6d86525 | ||
|
|
7c148ed1cb | ||
|
|
4d754935a9 | ||
|
|
0d26c9fb0b | ||
|
|
7d97e3d82f | ||
|
|
5aebdc9bcb | ||
|
|
a969c6a6a6 | ||
|
|
03829d8e1d | ||
|
|
86b9d3b4e5 | ||
|
|
9bd5838646 | ||
|
|
80cb285819 | ||
|
|
d77348f830 | ||
|
|
0820cbcb35 | ||
|
|
221bba1897 | ||
|
|
b0f6d3e112 | ||
|
|
7ed9c590b9 | ||
|
|
ed8b0e4b1e | ||
|
|
d55f4fbda1 | ||
|
|
171394e914 | ||
|
|
66326065b0 | ||
|
|
a7dbf6f5a5 | ||
|
|
bdf83c353f | ||
|
|
8afa3bb2fa | ||
|
|
e7fd0985c9 | ||
|
|
04a939d640 | ||
|
|
c922af2737 | ||
|
|
162f9a3c90 | ||
|
|
840fd69730 | ||
|
|
9a03902ab6 | ||
|
|
09459ed000 | ||
|
|
ae03e4ffc6 | ||
|
|
57a38f071b | ||
|
|
5a8f2fe31d | ||
|
|
2946a9286b | ||
|
|
6801afa12f | ||
|
|
ef4d6ab988 | ||
|
|
efdc17513d | ||
|
|
5d573c976e | ||
|
|
b071e618e7 | ||
|
|
1fce55cf5d | ||
|
|
90db524a90 | ||
|
|
62a39d60ce | ||
|
|
29656cb9e0 | ||
|
|
8b70834035 | ||
|
|
e3baa1cdda | ||
|
|
42f9f507b6 | ||
|
|
bf8eaaa9a5 | ||
|
|
6f836c45aa | ||
|
|
5405bdd344 | ||
|
|
2d399f5d4a | ||
|
|
7740f1a6bb | ||
|
|
a791274824 | ||
|
|
eb16763bff | ||
|
|
943738671c | ||
|
|
6f3d7516dc | ||
|
|
bd86c692cf | ||
|
|
b7548dbf29 | ||
|
|
a397141d78 | ||
|
|
f3f06dafe3 |
@@ -69,7 +69,7 @@ services:
|
||||
hard: -1
|
||||
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:v1.6.0
|
||||
image: libretranslate/libretranslate:v1.6.1
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- lt-data:/home/libretranslate/.local
|
||||
|
||||
@@ -73,6 +73,16 @@ DB_PORT=5432
|
||||
SECRET_KEY_BASE=
|
||||
OTP_SECRET=
|
||||
|
||||
# Encryption secrets
|
||||
# ------------------
|
||||
# Must be available (and set to same values) for all server processes
|
||||
# These are private/secret values, do not share outside hosting environment
|
||||
# Use `bin/rails db:encryption:init` to generate fresh secrets
|
||||
# Do not change these secrets once in use, as this would cause data loss and other issues
|
||||
# ------------------
|
||||
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
|
||||
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
|
||||
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
|
||||
|
||||
# Web Push
|
||||
# --------
|
||||
|
||||
6
.github/workflows/build-push-pr.yml
vendored
6
.github/workflows/build-push-pr.yml
vendored
@@ -21,9 +21,11 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- id: version_vars
|
||||
run: |
|
||||
echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT
|
||||
echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT
|
||||
echo mastodon_short_sha=$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT
|
||||
outputs:
|
||||
metadata: ${{ steps.version_vars.outputs.mastodon_version_metadata }}
|
||||
short_sha: ${{ steps.version_vars.outputs.mastodon_short_sha }}
|
||||
|
||||
build-image:
|
||||
needs: compute-suffix
|
||||
@@ -39,6 +41,7 @@ jobs:
|
||||
latest=auto
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=ref,event=pr,suffix=-${{ needs.compute-suffix.outputs.short_sha }}
|
||||
secrets: inherit
|
||||
|
||||
build-image-streaming:
|
||||
@@ -55,4 +58,5 @@ jobs:
|
||||
latest=auto
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=ref,event=pr,suffix=-${{ needs.compute-suffix.outputs.short_sha }}
|
||||
secrets: inherit
|
||||
|
||||
2
.github/workflows/build-releases.yml
vendored
2
.github/workflows/build-releases.yml
vendored
@@ -22,7 +22,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.2.') }}
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
|
||||
70
.github/workflows/crowdin-download-stable.yml
vendored
Normal file
70
.github/workflows/crowdin-download-stable.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Crowdin / Download translations (stable branches)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
download-translations-stable:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'glitch-soc/mastodon'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Increase Git http.postBuffer
|
||||
# This is needed due to a bug in Ubuntu's cURL version?
|
||||
# See https://github.com/orgs/community/discussions/55820
|
||||
run: |
|
||||
git config --global http.version HTTP/1.1
|
||||
git config --global http.postBuffer 157286400
|
||||
|
||||
# Download the translation files from Crowdin
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin-glitch.yml
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
crowdin_branch_name: ${{ github.base_ref || github.ref_name }}
|
||||
push_translations: false
|
||||
create_pull_request: false
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
# As the files are extracted from a Docker container, they belong to root:root
|
||||
# We need to fix this before the next steps
|
||||
- name: Fix file permissions
|
||||
run: sudo chown -R runner:docker .
|
||||
|
||||
# This is needed to run the normalize step
|
||||
- name: Set up Ruby environment
|
||||
uses: ./.github/actions/setup-ruby
|
||||
|
||||
- name: Run i18n normalize task
|
||||
run: bundle exec i18n-tasks normalize
|
||||
|
||||
# Create or update the pull request
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7.0.5
|
||||
with:
|
||||
commit-message: 'New Crowdin translations'
|
||||
title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)'
|
||||
author: 'GitHub Actions <noreply@github.com>'
|
||||
body: |
|
||||
New Crowdin translations, automated with GitHub Actions
|
||||
|
||||
See `.github/workflows/crowdin-download.yml`
|
||||
|
||||
This PR will be updated every day with new translations.
|
||||
|
||||
Due to a limitation in GitHub Actions, checks are not running on this PR without manual action.
|
||||
If you want to run the checks, then close and re-open it.
|
||||
branch: i18n/crowdin/translations-${{ github.base_ref || github.ref_name }}
|
||||
base: ${{ github.base_ref || github.ref_name }}
|
||||
labels: i18n
|
||||
2
.github/workflows/crowdin-download.yml
vendored
2
.github/workflows/crowdin-download.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
# Create or update the pull request
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7.0.1
|
||||
uses: peter-evans/create-pull-request@v7.0.5
|
||||
with:
|
||||
commit-message: 'New Crowdin translations'
|
||||
title: 'New Crowdin Translations (automated)'
|
||||
|
||||
3
.github/workflows/crowdin-upload.yml
vendored
3
.github/workflows/crowdin-upload.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: Crowdin / Upload translations
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
@@ -32,7 +31,7 @@ jobs:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
crowdin_branch_name: main
|
||||
crowdin_branch_name: ${{ github.base_ref || github.ref_name }}
|
||||
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
||||
|
||||
2
.github/workflows/test-migrations.yml
vendored
2
.github/workflows/test-migrations.yml
vendored
@@ -32,6 +32,8 @@ jobs:
|
||||
postgres:
|
||||
- 14-alpine
|
||||
- 15-alpine
|
||||
- 16-alpine
|
||||
- 17-alpine
|
||||
|
||||
services:
|
||||
postgres:
|
||||
|
||||
8
.github/workflows/test-ruby.yml
vendored
8
.github/workflows/test-ruby.yml
vendored
@@ -143,7 +143,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-ruby
|
||||
with:
|
||||
ruby-version: ${{ matrix.ruby-version}}
|
||||
additional-system-dependencies: ffmpeg libpam-dev
|
||||
additional-system-dependencies: ffmpeg imagemagick libpam-dev
|
||||
|
||||
- name: Load database schema
|
||||
run: |
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-ruby
|
||||
with:
|
||||
ruby-version: ${{ matrix.ruby-version}}
|
||||
additional-system-dependencies: ffmpeg libpam-dev libyaml-dev
|
||||
additional-system-dependencies: ffmpeg libpam-dev
|
||||
|
||||
- name: Load database schema
|
||||
run: './bin/rails db:create db:schema:load db:seed'
|
||||
@@ -325,7 +325,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-ruby
|
||||
with:
|
||||
ruby-version: ${{ matrix.ruby-version}}
|
||||
additional-system-dependencies: ffmpeg
|
||||
additional-system-dependencies: ffmpeg imagemagick
|
||||
|
||||
- name: Set up Javascript environment
|
||||
uses: ./.github/actions/setup-javascript
|
||||
@@ -445,7 +445,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-ruby
|
||||
with:
|
||||
ruby-version: ${{ matrix.ruby-version}}
|
||||
additional-system-dependencies: ffmpeg
|
||||
additional-system-dependencies: ffmpeg imagemagick
|
||||
|
||||
- name: Set up Javascript environment
|
||||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
148
CHANGELOG.md
148
CHANGELOG.md
@@ -2,7 +2,91 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.3.0] - UNRELEASED
|
||||
## [4.3.2] - 2024-12-03
|
||||
|
||||
### Added
|
||||
|
||||
- Add `tootctl feeds vacuum` (#33065 by @ClearlyClaire)
|
||||
- Add error message when user tries to follow their own account (#31910 by @lenikadali)
|
||||
- Add client_secret_expires_at to OAuth Applications (#30317 by @ThisIsMissEm)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change design of Content Warnings and filters (#32543 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix processing incoming post edits with mentions to unresolvable accounts (#33129 by @ClearlyClaire)
|
||||
- Fix error when including multiple instances of `embed.js` (#33107 by @YKWeyer)
|
||||
- Fix inactive users' timelines being backfilled on follow and unsuspend (#33094 by @ClearlyClaire)
|
||||
- Fix direct inbox delivery pushing posts into inactive followers' timelines (#33067 by @ClearlyClaire)
|
||||
- Fix `TagFollow` records not being correctly handled in account operations (#33063 by @ClearlyClaire)
|
||||
- Fix pushing hashtag-followed posts to feeds of inactive users (#33018 by @Gargron)
|
||||
- Fix duplicate notifications in notification groups when using slow mode (#33014 by @ClearlyClaire)
|
||||
- Fix posts made in the future being allowed to trend (#32996 by @ClearlyClaire)
|
||||
- Fix uploading higher-than-wide GIF profile picture with libvips enabled (#32911 by @ClearlyClaire)
|
||||
- Fix domain attribution field having autocorrect and autocapitalize enabled (#32903 by @ClearlyClaire)
|
||||
- Fix titles being escaped twice (#32889 by @ClearlyClaire)
|
||||
- Fix list creation limit check (#32869 by @ClearlyClaire)
|
||||
- Fix error in `tootctl email_domain_blocks` when supplying `--with-dns-records` (#32863 by @mjankowski)
|
||||
- Fix `min_id` and `max_id` causing error in search API (#32857 by @Gargron)
|
||||
- Fix inefficiencies when processing removal of posts that use featured tags (#32787 by @ClearlyClaire)
|
||||
- Fix alt-text pop-in not using the translated description (#32766 by @ClearlyClaire)
|
||||
- Fix preview cards with long titles erroneously causing layout changes (#32678 by @ClearlyClaire)
|
||||
- Fix embed modal layout on mobile (#32641 by @DismalShadowX)
|
||||
- Fix and improve batch attachment deletion handling when using OpenStack Swift (#32637 by @hugogameiro)
|
||||
- Fix blocks not being applied on link timeline (#32625 by @tribela)
|
||||
- Fix follow counters being incorrectly changed (#32622 by @oneiros)
|
||||
- Fix 'unknown' media attachment type rendering (#32613 and #32713 by @ThisIsMissEm and @renatolond)
|
||||
- Fix tl language native name (#32606 by @seav)
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies
|
||||
|
||||
## [4.3.1] - 2024-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- Add more explicit explanations about author attribution and `fediverse:creator` (#32383 by @ClearlyClaire)
|
||||
- Add ability to group follow notifications in WebUI, can be disabled in the column settings (#32520 by @renchap)
|
||||
- Add back a 6 hours mute duration option (#32522 by @renchap)
|
||||
- Add note about not changing ActiveRecord encryption secrets once they are set (#32413, #32476, #32512, and #32537 by @ClearlyClaire and @mjankowski)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change translation feature to translate to selected regional variant (e.g. pt-BR) if available (#32428 by @c960657)
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove ability to get embed code for remote posts (#32578 by @ClearlyClaire)\
|
||||
Getting the embed code is only reliable for local posts.\
|
||||
It never worked for non-Mastodon servers, and stopped working correctly with the changes made in 4.3.0.\
|
||||
We have therefore decided to remove the menu entry while we investigate solutions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix follow recommendation moderation page default language when using regional variant (#32580 by @ClearlyClaire)
|
||||
- Fix column-settings spacing in local timeline in advanced view (#32567 by @lindwurm)
|
||||
- Fix broken i18n in text welcome mailer tags area (#32571 by @mjankowski)
|
||||
- Fix missing or incorrect cache-control headers for Streaming server (#32551 by @ThisIsMissEm)
|
||||
- Fix only the first paragraph being displayed in some notifications (#32348 by @ClearlyClaire)
|
||||
- Fix reblog icons on account media view (#32506 by @tribela)
|
||||
- Fix Content-Security-Policy not allowing OpenStack SWIFT object storage URI (#32439 by @kenkiku1021)
|
||||
- Fix back arrow pointing to the incorrect direction in RTL languages (#32485 by @renchap)
|
||||
- Fix streaming server using `REDIS_USERNAME` instead of `REDIS_USER` (#32493 by @ThisIsMissEm)
|
||||
- Fix follow recommendation carrousel scrolling on RTL layouts (#32462 and #32505 by @ClearlyClaire)
|
||||
- Fix follow recommendation suppressions not applying immediately (#32392 by @ClearlyClaire)
|
||||
- Fix language of push notifications (#32415 by @ClearlyClaire)
|
||||
- Fix mute duration not being shown in list of muted accounts in web UI (#32388 by @ClearlyClaire)
|
||||
- Fix “Mark every notification as read” not updating the read marker if scrolled down (#32385 by @ClearlyClaire)
|
||||
- Fix “Mention” appearing for otherwise filtered posts (#32356 by @ClearlyClaire)
|
||||
- Fix notification requests from suspended accounts still being listed (#32354 by @ClearlyClaire)
|
||||
- Fix list edition modal styling (#32358 and #32367 by @ClearlyClaire and @vmstan)
|
||||
- Fix 4 columns barely not fitting on 1920px screen (#32361 by @ClearlyClaire)
|
||||
- Fix icon alignment in applications list (#32293 by @mjankowski)
|
||||
|
||||
## [4.3.0] - 2024-10-08
|
||||
|
||||
The following changelog entries focus on changes visible to users, administrators, client developers or federated software developers, but there has also been a lot of code modernization, refactoring, and tooling work, in particular by @mjankowski.
|
||||
|
||||
@@ -10,12 +94,13 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
|
||||
- **Add confirmation interstitial instead of silently redirecting logged-out visitors to remote resources** (#27792, #28902, and #30651 by @ClearlyClaire and @Gargron)\
|
||||
This fixes a longstanding open redirect in Mastodon, at the cost of added friction when local links to remote resources are shared.
|
||||
- Change `form-action` Content-Security-Policy directive to be more restrictive (#26897 by @ClearlyClaire)
|
||||
- Fix ReDoS vulnerability on some Ruby versions ([GHSA-jpxp-r43f-rhvx](https://github.com/mastodon/mastodon/security/advisories/GHSA-jpxp-r43f-rhvx))
|
||||
- Change `form-action` Content-Security-Policy directive to be more restrictive (#26897 and #32241 by @ClearlyClaire)
|
||||
- Update dependencies
|
||||
|
||||
### Added
|
||||
|
||||
- **Add server-side notification grouping** (#29889, #30576, #30685, #30688, #30707, #30776, #30779, #30781, #30440, #31062, #31098, #31076, #31111, #31123, #31223, #31214, #31224, #31299, #31325, #31347, #31304, #31326, #31384, #31403, #31433, #31509, #31486, #31513, #31592, #31594, #31638, #31746, #31652, #31709, #31725, #31745, #31613, #31657, #31840, #31610 and #31929 by @ClearlyClaire, @Gargron, @mgmn, and @renchap)\
|
||||
- **Add server-side notification grouping** (#29889, #30576, #30685, #30688, #30707, #30776, #30779, #30781, #30440, #31062, #31098, #31076, #31111, #31123, #31223, #31214, #31224, #31299, #31325, #31347, #31304, #31326, #31384, #31403, #31433, #31509, #31486, #31513, #31592, #31594, #31638, #31746, #31652, #31709, #31725, #31745, #31613, #31657, #31840, #31610, #31929, #32089, #32085, #32243, #32179 and #32254 by @ClearlyClaire, @Gargron, @mgmn, and @renchap)\
|
||||
Group notifications of the same type for the same target, so that your notifications no longer get cluttered by boost and favorite notifications as soon as a couple of your posts get traction.\
|
||||
This is done server-side so that clients can efficiently get relevant groups without having to go through numerous pages of individual notifications.\
|
||||
As part of this, the visual design of the entire notifications feature has been revamped.\
|
||||
@@ -27,7 +112,7 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- `GET /api/v2/notifications/:group_key/accounts`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts
|
||||
- `POST /api/v2/notifications/:group_key/dimsiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group
|
||||
- `GET /api/v2/notifications/:unread_count`: https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count
|
||||
- **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, and #31723 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\
|
||||
- **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, #31723, #32062 and #32281 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\
|
||||
The old “Block notifications from non-followers”, “Block notifications from people you don't follow” and “Block direct messages from people you don't follow” notification settings have been replaced by a new set of settings found directly in the notification column.\
|
||||
You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\
|
||||
Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\
|
||||
@@ -60,13 +145,13 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- **Add timeline of public posts about a trending link** (#30381 and #30840 by @Gargron)\
|
||||
You can now see public posts mentioning currently-trending articles from people who have opted into discovery features.\
|
||||
This adds a new REST API endpoint: https://docs.joinmastodon.org/methods/timelines/#link
|
||||
- **Add author highlight for news articles whose authors are on the fediverse** (#30398, #30670, #30521, #30846, #31819, and #31900 by @Gargron and @oneiros)\
|
||||
- **Add author highlight for news articles whose authors are on the fediverse** (#30398, #30670, #30521, #30846, #31819, #31900 and #32188 by @Gargron, @mjankowski and @oneiros)\
|
||||
This adds a mechanism to [highlight the author of news articles](https://blog.joinmastodon.org/2024/07/highlighting-journalism-on-mastodon/) shared on Mastodon.\
|
||||
Articles hosted outside the fediverse can indicate a fediverse author with a meta tag:
|
||||
```html
|
||||
<meta name="fediverse:creator" content="username@domain" />
|
||||
```
|
||||
On the API side, this is represented by a new `authors` attribute to the `PreviewCard` entity: https://docs.joinmastodon.org/entities/PreviewCard/#authors\
|
||||
On the API side, this is represented by a new `authors` attribute to the `PreviewCard` entity: https://docs.joinmastodon.org/entities/PreviewCard/#authors \
|
||||
Users can allow arbitrary domains to use `fediverse:creator` to credit them by visiting `/settings/verification`.\
|
||||
This is federated as a new `attributionDomains` property in the `http://joinmastodon.org/ns` namespace, containing an array of domain names: https://docs.joinmastodon.org/spec/activitypub/#properties-used-1
|
||||
- **Add in-app notifications for moderation actions and warnings** (#30065, #30082, and #30081 by @ClearlyClaire)\
|
||||
@@ -76,7 +161,11 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
Clicking the domain of a user in their profile will now open a tooltip with a short explanation about servers and federation.
|
||||
- **Add support for Redis sentinel** (#31694, #31623, #31744, #31767, and #31768 by @ThisIsMissEm and @oneiros)\
|
||||
See https://docs.joinmastodon.org/admin/scaling/#redis-sentinel
|
||||
- Add ability to reorder uploaded media before posting in web UI (#28456 by @Gargron)
|
||||
- **Add ability to reorder uploaded media before posting in web UI** (#28456 and #32093 by @Gargron)
|
||||
- Add “A Mastodon update is available.” message on admin dashboard for non-bugfix updates (#32106 by @ClearlyClaire)
|
||||
- Add ability to view alt text by clicking the ALT badge in web UI (#32058 by @Gargron)
|
||||
- Add preview of followers removed in domain block modal in web UI (#32032 and #32105 by @ClearlyClaire and @Gargron)
|
||||
- Add reblogs and favourites counts to statuses in ActivityPub (#32007 by @Gargron)
|
||||
- Add moderation interface for searching hashtags (#30880 by @ThisIsMissEm)
|
||||
- Add ability for admins to configure instance favicon and logo (#30040, #30208, #30259, #30375, #30734, #31016, and #30205 by @ClearlyClaire, @FawazFarid, @JasonPunyon, @mgmn, and @renchap)\
|
||||
This is also exposed through the REST API: https://docs.joinmastodon.org/entities/Instance/#icon
|
||||
@@ -122,14 +211,14 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- Add Interlingue and Interlingua to interface languages (#28630 and #30828 by @Dhghomon and @renchap)
|
||||
- Add Kashubian, Pennsylvania Dutch, Vai, Jawi Malay, Mohawk and Low German to posting languages (#26024, #26634, #27136, #29098, #27115, and #27434 by @EngineerDali, @HelgeKrueger, and @gunchleoc)
|
||||
- Add option to use native Ruby driver for Redis through `REDIS_DRIVER=ruby` (#30717 by @vmstan)
|
||||
- Add support for libvips in addition to ImageMagick (#30090, #30590, #30597, #30632, #30857, #30869, and #30858 by @ClearlyClaire, @Gargron, and @mjankowski)\
|
||||
- Add support for libvips in addition to ImageMagick (#30090, #30590, #30597, #30632, #30857, #30869, #30858 and #32104 by @ClearlyClaire, @Gargron, and @mjankowski)\
|
||||
Server admins can now use libvips as a faster and lighter alternative to ImageMagick for processing user-uploaded images.\
|
||||
This requires libvips 8.13 or newer, and needs to be enabled with `MASTODON_USE_LIBVIPS=true`.\
|
||||
This is enabled by default in the official Docker images, and is intended to completely replace ImageMagick in the future.
|
||||
- Add validations to `Web::PushSubscription` (#30540 and #30542 by @ThisIsMissEm)
|
||||
- Add anchors to each authorized application in `/oauth/authorized_applications` (#31677 by @fowl2)
|
||||
- Add active animation to header settings button (#30221, #30307, and #30388 by @daudix)
|
||||
- Add OpenTelemetry instrumentation (#30130, #30322, #30353, and #30350 by @julianocosta89, @renchap, and @robbkidd)\
|
||||
- Add OpenTelemetry instrumentation (#30130, #30322, #30353, #30350 and #31998 by @julianocosta89, @renchap, @robbkidd and @timetinytim)\
|
||||
See https://docs.joinmastodon.org/admin/config/#otel for documentation
|
||||
- Add API to get multiple accounts and statuses (#27871 and #30465 by @ClearlyClaire)\
|
||||
This adds `GET /api/v1/accounts` and `GET /api/v1/statuses` to the REST API, see https://docs.joinmastodon.org/methods/accounts/#index and https://docs.joinmastodon.org/methods/statuses/#index
|
||||
@@ -138,7 +227,6 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- Add RFC8414 OAuth 2.0 server metadata (#29191 by @ThisIsMissEm)
|
||||
- Add loading indicator and empty result message to advanced interface search (#30085 by @ClearlyClaire)
|
||||
- Add `profile` OAuth 2.0 scope, allowing more limited access to user data (#29087 and #30357 by @ThisIsMissEm)
|
||||
- Add global Regexp timeout (#31928 by @ClearlyClaire)
|
||||
- Add the role ID to the badge component (#29707 by @renchap)
|
||||
- Add diagnostic message for failure during CLI search deploy (#29462 by @mjankowski)
|
||||
- Add pagination `Link` headers on API accounts/statuses when pinned true (#29442 by @mjankowski)
|
||||
@@ -146,10 +234,12 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- Add groundwork for annual reports for accounts (#28693 by @Gargron)\
|
||||
This lays the groundwork for a “year-in-review”/“wrapped” style report for local users, but is currently not in use.
|
||||
- Add notification email on invalid second authenticator (#28822 by @ClearlyClaire)
|
||||
- Add date of account deletion in list of accounts in the admin interface (#25640 by @tribela)
|
||||
- Add new emojis from `jdecked/twemoji` 15.0 (#28404 by @TheEssem)
|
||||
- Add configurable error handling in attachment batch deletion (#28184 by @vmstan)\
|
||||
This makes the S3 batch size configurable through the `S3_BATCH_DELETE_LIMIT` environment variable (defaults to 1000), and adds some retry logic, configurable through the `S3_BATCH_DELETE_RETRY` environment variable (defaults to 3).
|
||||
- Add VAPID public key to instance serializer (#28006 by @ThisIsMissEm)
|
||||
- Add support for serving JRD `/.well-known/host-meta.json` in addition to XRD host-meta (#32206 by @c960657)
|
||||
- Add `nodeName` and `nodeDescription` to nodeinfo `metadata` (#28079 by @6543)
|
||||
- Add Thai diacritics and tone marks in `HASHTAG_INVALID_CHARS_RE` (#26576 by @ppnplus)
|
||||
- Add variable delay before link verification of remote account links (#27774 by @ClearlyClaire)
|
||||
@@ -164,18 +254,18 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
|
||||
### Changed
|
||||
|
||||
- **Change icons throughout the web interface** (#27385, #27539, #27555, #27579, #27700, #27817, #28519, #28709, #28064, #28775, #28780, #27924, #29294, #29395, #29537, #29569, #29610, #29612, #29649, #29844, #27780, #30974, #30963, #30962, #30961, #31362, #31363, #31359, #31371, #31360, #31512, #31511, and #31525 by @ClearlyClaire, @Gargron, @arbolitoloco1, @mjankowski, @nclm, @renchap, @ronilaukkarinen, and @zunda)\
|
||||
- **Change icons throughout the web interface** (#27385, #27539, #27555, #27579, #27700, #27817, #28519, #28709, #28064, #28775, #28780, #27924, #29294, #29395, #29537, #29569, #29610, #29612, #29649, #29844, #27780, #30974, #30963, #30962, #30961, #31362, #31363, #31359, #31371, #31360, #31512, #31511, #31525, #32153, and #32201 by @ClearlyClaire, @Gargron, @arbolitoloco1, @mjankowski, @nclm, @renchap, @ronilaukkarinen, and @zunda)\
|
||||
This changes all the interface icons from FontAwesome to Material Symbols for a more modern look, consistent with the official Mastodon Android app.\
|
||||
In addition, better care is given to pixel alignment, and icon variants are used to better highlight active/inactive state.
|
||||
- **Change design of compose form in web UI** (#28119, #29059, #29248, #29372, #29384, #29417, #29456, #29406, #29651, #29659, and #31889 by @ClearlyClaire, @Gargron, @eai04191, @hinaloe, and @ronilaukkarinen)\
|
||||
- **Change design of compose form in web UI** (#28119, #29059, #29248, #29372, #29384, #29417, #29456, #29406, #29651, #29659, #31889 and #32033 by @ClearlyClaire, @Gargron, @eai04191, @hinaloe, and @ronilaukkarinen)\
|
||||
The compose form has been completely redesigned for a more modern and consistent look, as well as spelling out the chosen privacy setting and language name at all times.\
|
||||
As part of this, the “Unlisted” privacy setting has been renamed to “Quiet public”.
|
||||
- **Change design of modals in the web UI** (#29576, #29614, #29640, #29644, #30131, #30884, #31399, #31555, #31752, #31801, #31883, #31844, #31864, and #31943 by @ClearlyClaire, @Gargron, @tribela and @vmstan)\
|
||||
The mute, block, and domain block confirmation modals have been completely redesigned to be clearer and include more detailed information on the action to be performed.\
|
||||
They also have a more modern and consistent design, along with other confirmation modals in the application.
|
||||
- **Change colors throughout the web UI** (#29522, #29584, #29653, #29779, #29803, #29809, #29808, #29828, #31034, #31168, #31266, #31348, #31349, #31361, and #31510 by @ClearlyClaire, @Gargron, @renchap, and @vmstan)
|
||||
- **Change colors throughout the web UI** (#29522, #29584, #29653, #29779, #29803, #29809, #29808, #29828, #31034, #31168, #31266, #31348, #31349, #31361, #31510 and #32128 by @ClearlyClaire, @Gargron, @mjankowski, @renchap, and @vmstan)
|
||||
- **Change onboarding prompt to follow suggestions carousel in web UI** (#28878, #29272, and #31912 by @Gargron)
|
||||
- **Change email templates** (#28416, #28755, #28814, #29064, #28883, #29470, #29607, #29761, #29760, and #29879 by @ClearlyClaire, @Gargron, @hteumeuleu, and @mjankowski)\
|
||||
- **Change email templates** (#28416, #28755, #28814, #29064, #28883, #29470, #29607, #29761, #29760, #29879, #32073 and #32132 by @c960657, @ClearlyClaire, @Gargron, @hteumeuleu, and @mjankowski)\
|
||||
All emails to end-users have been completely redesigned with a fresh new look, providing more information while making them easier to read and keeping maximum compatibility across mail clients.
|
||||
- **Change follow recommendations algorithm** (#28314, #28433, #29017, #29108, #29306, #29550, #29619, and #31474 by @ClearlyClaire, @Gargron, @kernal053, @mjankowski, and @wheatear-dev)\
|
||||
This replaces the “past interactions” recommendation algorithm with a “friends of friends” algorithm that suggests accounts followed by people you follow, and a “similar profiles” algorithm that suggests accounts with a profile similar to your most recent follows.\
|
||||
@@ -188,10 +278,17 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
Administrators may need to update their setup accordingly.
|
||||
- Change how content warnings and filters are displayed in web UI (#31365, and #31761 by @Gargron)
|
||||
- Change preview card processing to ignore `undefined` as canonical url (#31882 by @oneiros)
|
||||
- Change embedded posts to use web UI (#31766 by @Gargron)
|
||||
- Change embedded posts to use web UI (#31766, #32135 and #32271 by @Gargron)
|
||||
- Change inner borders in media galleries in web UI (#31852 by @Gargron)
|
||||
- Change design of hide media button in web UI (#31807 by @Gargron)
|
||||
- Change design of media attachments and profile media tab in web UI (#31807, #32048, #31967, #32217, #32224 and #32257 by @ClearlyClaire and @Gargron)
|
||||
- Change labels on thread indicators in web UI (#31806 by @Gargron)
|
||||
- Change label of "Data export" menu item in settings interface (#32099 by @c960657)
|
||||
- Change responsive break points on navigation panel in web UI (#32034 by @Gargron)
|
||||
- Change cursor to `not-allowed` on disabled buttons (#32076 by @mjankowski)
|
||||
- Change OAuth authorization prompt to not refer to apps as “third-party” (#32005 by @Gargron)
|
||||
- Change Mastodon to issue correct HTTP signatures by default (#31994 by @ClearlyClaire)
|
||||
- Change zoom icon in web UI (#29683 by @Gargron)
|
||||
- Change directory page to use URL query strings for options (#31980, #31977 and #31984 by @ClearlyClaire and @renchap)
|
||||
- Change report action buttons to be disabled when action has already been taken (#31773, #31822, and #31899 by @ClearlyClaire and @ThisIsMissEm)
|
||||
- Change width of columns in advanced web UI (#31762 by @Gargron)
|
||||
- Change design of unread conversations in web UI (#31763 by @Gargron)
|
||||
@@ -254,6 +351,7 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove unused E2EE messaging code and related `crypto` OAuth scope (#31193, #31945, #31963, and #31964 by @ClearlyClaire and @mjankowski)
|
||||
- Remove StatsD integration (replaced by OpenTelemetry) (#30240 by @mjankowski)
|
||||
- Remove `CacheBuster` default options (#30718 by @mjankowski)
|
||||
- Remove home marker updates from the Web UI (#22721 by @davbeck)\
|
||||
@@ -269,9 +367,22 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- Fix log out from user menu not working on Safari (#31402 by @renchap)
|
||||
- Fix various issues when in link preview card generation (#28748, #30017, #30362, #30173, #30853, #30929, #30933, #30957, #30987, and #31144 by @adamniedzielski, @oneiros, @phocks, @timothyjrogers, and @tribela)
|
||||
- Fix handling of missing links in Webfinger responses (#31030 by @adamniedzielski)
|
||||
- Fix error when accepting an appeal for sensitive posts deleted in the meantime (#32037 by @ClearlyClaire)
|
||||
- Fix error when encountering reblog of deleted post in feed rebuild (#32001 by @ClearlyClaire)
|
||||
- Fix Safari browser glitch related to horizontal scrolling (#31960 by @Gargron)
|
||||
- Fix unresolvable mentions sometimes preventing processing incoming posts (#29215 by @tribela and @ClearlyClaire)
|
||||
- Fix too many requests caused by relationship look-ups in web UI (#32042 by @Gargron)
|
||||
- Fix links for reblogs in moderation interface (#31979 by @ClearlyClaire)
|
||||
- Fix the appearance of avatars when they do not load (#31966 and #32270 by @Gargron and @renchap)
|
||||
- Fix spurious error notifications for aborted requests in web UI (#31952 by @c960657)
|
||||
- Fix HTTP 500 error in `/api/v1/polls/:id/votes` when required `choices` parameter is missing (#25598 by @danielmbrasil)
|
||||
- Fix security context sometimes not being added in LD-Signed activities (#31871 by @ClearlyClaire)
|
||||
- Fix cross-origin loading of `inert.css` polyfill (#30687 by @louis77)
|
||||
- Fix wrapping in dashboard quick access buttons (#32043 by @renchap)
|
||||
- Fix recently used tags hint being displayed in profile edition page when there is none (#32120 by @mjankowski)
|
||||
- Fix checkbox lists on narrow screens in the settings interface (#32112 by @mjankowski)
|
||||
- Fix the position of status action buttons being affected by interaction counters (#32084 by @renchap)
|
||||
- Fix the summary of converted ActivityPub object types to be treated as HTML (#28629 by @Menrath)
|
||||
- Fix cutoff of instance name in sign-up form (#30598 by @oneiros)
|
||||
- Fix invalid date searches returning 503 errors (#31526 by @notchairmk)
|
||||
- Fix invalid `visibility` values in `POST /api/v1/statuses` returning 500 errors (#31571 by @c960657)
|
||||
@@ -285,10 +396,12 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- Fix “Redirect URI” field not being marked as required in “New application” form (#30311 by @ThisIsMissEm)
|
||||
- Fix right-to-left text in preview cards (#30930 by @ClearlyClaire)
|
||||
- Fix rack attack `match_type` value typo in logging config (#30514 by @mjankowski)
|
||||
- Fix various cases of duplicate, missing, or inconsistent borders or scrollbar styles (#31068, #31286, #31268, #31275, #31284, #31305, #31346, #31372, #31373, #31389, #31432, #31391, and #31445 by @valtlai and @vmstan)
|
||||
- Fix various cases of duplicate, missing, or inconsistent borders or scrollbar styles (#31068, #31286, #31268, #31275, #31284, #31305, #31346, #31372, #31373, #31389, #31432, #31391, #31445, #32091, #32147 and #32137 by @ClearlyClaire, @mjankowski, @valtlai and @vmstan)
|
||||
- Fix editing description of media uploads with custom thumbnails (#32221 by @ClearlyClaire)
|
||||
- Fix race condition in `POST /api/v1/push/subscription` (#30166 by @ClearlyClaire)
|
||||
- Fix post deletion not being delayed when those are part of an account warning (#30163 by @ClearlyClaire)
|
||||
- Fix rendering error on `/start` when not logged in (#30023 by @timothyjrogers)
|
||||
- Fix unneeded requests to blocked domains when receiving relayed signed activities from them (#31161 by @ClearlyClaire)
|
||||
- Fix logo pushing header buttons out of view on certain conditions in mobile layout (#29787 by @ClearlyClaire)
|
||||
- Fix notification-related records not being reattributed when merging accounts (#29694 by @ClearlyClaire)
|
||||
- Fix results/query in `api/v1/featured_tags/suggestions` (#29597 by @mjankowski)
|
||||
@@ -298,6 +411,7 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- Fix full date display not respecting the locale 12/24h format (#29448 by @renchap)
|
||||
- Fix filters title and keywords overflow (#29396 by @GeopJr)
|
||||
- Fix incorrect date format in “Follows and followers” (#29390 by @JasonPunyon)
|
||||
- Fix navigation item active highlight for some paths (#32159 by @mjankowski)
|
||||
- Fix “Edit media” modal sizing and layout when space-constrained (#27095 by @ronilaukkarinen)
|
||||
- Fix modal container bounds (#29185 by @nico3333fr)
|
||||
- Fix inefficient HTTP signature parsing using regexps and `StringScanner` (#29133 by @ClearlyClaire)
|
||||
|
||||
1
Gemfile
1
Gemfile
@@ -47,7 +47,6 @@ gem 'color_diff', '~> 0.1'
|
||||
gem 'csv', '~> 3.2'
|
||||
gem 'discard', '~> 1.2'
|
||||
gem 'doorkeeper', '~> 5.6'
|
||||
gem 'ed25519', '~> 1.3'
|
||||
gem 'fast_blank', '~> 1.0'
|
||||
gem 'fastimage'
|
||||
gem 'hiredis', '~> 0.6'
|
||||
|
||||
156
Gemfile.lock
156
Gemfile.lock
@@ -10,35 +10,35 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.1.4)
|
||||
actionpack (= 7.1.4)
|
||||
activesupport (= 7.1.4)
|
||||
actioncable (7.1.4.1)
|
||||
actionpack (= 7.1.4.1)
|
||||
activesupport (= 7.1.4.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.1.4)
|
||||
actionpack (= 7.1.4)
|
||||
activejob (= 7.1.4)
|
||||
activerecord (= 7.1.4)
|
||||
activestorage (= 7.1.4)
|
||||
activesupport (= 7.1.4)
|
||||
actionmailbox (7.1.4.1)
|
||||
actionpack (= 7.1.4.1)
|
||||
activejob (= 7.1.4.1)
|
||||
activerecord (= 7.1.4.1)
|
||||
activestorage (= 7.1.4.1)
|
||||
activesupport (= 7.1.4.1)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.1.4)
|
||||
actionpack (= 7.1.4)
|
||||
actionview (= 7.1.4)
|
||||
activejob (= 7.1.4)
|
||||
activesupport (= 7.1.4)
|
||||
actionmailer (7.1.4.1)
|
||||
actionpack (= 7.1.4.1)
|
||||
actionview (= 7.1.4.1)
|
||||
activejob (= 7.1.4.1)
|
||||
activesupport (= 7.1.4.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.1.4)
|
||||
actionview (= 7.1.4)
|
||||
activesupport (= 7.1.4)
|
||||
actionpack (7.1.4.1)
|
||||
actionview (= 7.1.4.1)
|
||||
activesupport (= 7.1.4.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
@@ -46,15 +46,15 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
actiontext (7.1.4)
|
||||
actionpack (= 7.1.4)
|
||||
activerecord (= 7.1.4)
|
||||
activestorage (= 7.1.4)
|
||||
activesupport (= 7.1.4)
|
||||
actiontext (7.1.4.1)
|
||||
actionpack (= 7.1.4.1)
|
||||
activerecord (= 7.1.4.1)
|
||||
activestorage (= 7.1.4.1)
|
||||
activesupport (= 7.1.4.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.1.4)
|
||||
activesupport (= 7.1.4)
|
||||
actionview (7.1.4.1)
|
||||
activesupport (= 7.1.4.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
@@ -64,22 +64,22 @@ GEM
|
||||
activemodel (>= 4.1)
|
||||
case_transform (>= 0.2)
|
||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||
activejob (7.1.4)
|
||||
activesupport (= 7.1.4)
|
||||
activejob (7.1.4.1)
|
||||
activesupport (= 7.1.4.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.1.4)
|
||||
activesupport (= 7.1.4)
|
||||
activerecord (7.1.4)
|
||||
activemodel (= 7.1.4)
|
||||
activesupport (= 7.1.4)
|
||||
activemodel (7.1.4.1)
|
||||
activesupport (= 7.1.4.1)
|
||||
activerecord (7.1.4.1)
|
||||
activemodel (= 7.1.4.1)
|
||||
activesupport (= 7.1.4.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.1.4)
|
||||
actionpack (= 7.1.4)
|
||||
activejob (= 7.1.4)
|
||||
activerecord (= 7.1.4)
|
||||
activesupport (= 7.1.4)
|
||||
activestorage (7.1.4.1)
|
||||
actionpack (= 7.1.4.1)
|
||||
activejob (= 7.1.4.1)
|
||||
activerecord (= 7.1.4.1)
|
||||
activesupport (= 7.1.4.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.1.4)
|
||||
activesupport (7.1.4.1)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
@@ -100,20 +100,20 @@ GEM
|
||||
attr_required (1.0.2)
|
||||
awrence (1.2.1)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.974.0)
|
||||
aws-sdk-core (3.205.0)
|
||||
aws-partitions (1.978.0)
|
||||
aws-sdk-core (3.209.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.91.0)
|
||||
aws-sdk-core (~> 3, >= 3.205.0)
|
||||
aws-sdk-kms (1.94.0)
|
||||
aws-sdk-core (~> 3, >= 3.207.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.162.0)
|
||||
aws-sdk-core (~> 3, >= 3.205.0)
|
||||
aws-sdk-s3 (1.166.0)
|
||||
aws-sdk-core (~> 3, >= 3.207.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.9.1)
|
||||
aws-sigv4 (1.10.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-storage-blob (2.0.3)
|
||||
azure-storage-common (~> 2.0)
|
||||
@@ -134,7 +134,7 @@ GEM
|
||||
bindata (2.5.0)
|
||||
binding_of_caller (1.0.1)
|
||||
debug_inspector (>= 1.2.0)
|
||||
blurhash (0.1.7)
|
||||
blurhash (0.1.8)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.2.1)
|
||||
@@ -197,7 +197,7 @@ GEM
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (5.1.0)
|
||||
devise-two-factor (6.0.0)
|
||||
activesupport (~> 7.0)
|
||||
devise (~> 4.0)
|
||||
railties (~> 7.0)
|
||||
@@ -212,9 +212,8 @@ GEM
|
||||
domain_name (0.6.20240107)
|
||||
doorkeeper (5.7.1)
|
||||
railties (>= 5)
|
||||
dotenv (3.1.2)
|
||||
dotenv (3.1.4)
|
||||
drb (2.2.1)
|
||||
ed25519 (1.3.0)
|
||||
elasticsearch (7.17.11)
|
||||
elasticsearch-api (= 7.17.11)
|
||||
elasticsearch-transport (= 7.17.11)
|
||||
@@ -290,7 +289,7 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (3.25.4)
|
||||
google-protobuf (3.25.5)
|
||||
googleapis-common-protos-types (1.15.0)
|
||||
google-protobuf (>= 3.18, < 5.a)
|
||||
haml (6.3.0)
|
||||
@@ -348,7 +347,7 @@ GEM
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.7.2)
|
||||
irb (1.14.0)
|
||||
irb (1.14.1)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
@@ -407,7 +406,7 @@ GEM
|
||||
llhttp-ffi (0.5.0)
|
||||
ffi-compiler (~> 1.0)
|
||||
rake (~> 13.0)
|
||||
logger (1.6.0)
|
||||
logger (1.6.1)
|
||||
lograge (0.14.0)
|
||||
actionpack (>= 4)
|
||||
activesupport (>= 4)
|
||||
@@ -429,7 +428,7 @@ GEM
|
||||
addressable (~> 2.5)
|
||||
azure-storage-blob (~> 2.0.1)
|
||||
hashie (~> 5.0)
|
||||
memory_profiler (1.0.2)
|
||||
memory_profiler (1.1.0)
|
||||
mime-types (3.5.2)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2024.0820)
|
||||
@@ -602,7 +601,7 @@ GEM
|
||||
actionmailer (>= 3)
|
||||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
propshaft (1.0.0)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
@@ -610,7 +609,7 @@ GEM
|
||||
psych (5.1.2)
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.4.2)
|
||||
puma (6.4.3)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.4.0)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -639,20 +638,20 @@ GEM
|
||||
rackup (1.0.0)
|
||||
rack (< 3)
|
||||
webrick
|
||||
rails (7.1.4)
|
||||
actioncable (= 7.1.4)
|
||||
actionmailbox (= 7.1.4)
|
||||
actionmailer (= 7.1.4)
|
||||
actionpack (= 7.1.4)
|
||||
actiontext (= 7.1.4)
|
||||
actionview (= 7.1.4)
|
||||
activejob (= 7.1.4)
|
||||
activemodel (= 7.1.4)
|
||||
activerecord (= 7.1.4)
|
||||
activestorage (= 7.1.4)
|
||||
activesupport (= 7.1.4)
|
||||
rails (7.1.4.1)
|
||||
actioncable (= 7.1.4.1)
|
||||
actionmailbox (= 7.1.4.1)
|
||||
actionmailer (= 7.1.4.1)
|
||||
actionpack (= 7.1.4.1)
|
||||
actiontext (= 7.1.4.1)
|
||||
actionview (= 7.1.4.1)
|
||||
activejob (= 7.1.4.1)
|
||||
activemodel (= 7.1.4.1)
|
||||
activerecord (= 7.1.4.1)
|
||||
activestorage (= 7.1.4.1)
|
||||
activesupport (= 7.1.4.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.1.4)
|
||||
railties (= 7.1.4.1)
|
||||
rails-controller-testing (1.0.5)
|
||||
actionpack (>= 5.0.1.rc1)
|
||||
actionview (>= 5.0.1.rc1)
|
||||
@@ -667,9 +666,9 @@ GEM
|
||||
rails-i18n (7.0.9)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 6.0.0, < 8)
|
||||
railties (7.1.4)
|
||||
actionpack (= 7.1.4)
|
||||
activesupport (= 7.1.4)
|
||||
railties (7.1.4.1)
|
||||
actionpack (= 7.1.4.1)
|
||||
activesupport (= 7.1.4.1)
|
||||
irb
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -699,7 +698,7 @@ GEM
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
rexml (3.3.7)
|
||||
rexml (3.3.9)
|
||||
rotp (6.3.0)
|
||||
rouge (4.3.0)
|
||||
rpam2 (4.0.2)
|
||||
@@ -749,15 +748,15 @@ GEM
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-capybara (2.21.0)
|
||||
rubocop (~> 1.41)
|
||||
rubocop-performance (1.21.1)
|
||||
rubocop-performance (1.22.1)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails (2.25.1)
|
||||
rubocop-rails (2.26.2)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
rubocop (>= 1.52.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rspec (3.0.4)
|
||||
rubocop-rspec (3.0.5)
|
||||
rubocop (~> 1.61)
|
||||
rubocop-rspec_rails (2.30.0)
|
||||
rubocop (~> 1.61)
|
||||
@@ -782,7 +781,7 @@ GEM
|
||||
scenic (1.8.0)
|
||||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
selenium-webdriver (4.24.0)
|
||||
selenium-webdriver (4.25.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
@@ -885,7 +884,7 @@ GEM
|
||||
webfinger (1.2.0)
|
||||
activesupport
|
||||
httpclient (>= 2.4)
|
||||
webmock (3.23.1)
|
||||
webmock (3.24.0)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
@@ -894,7 +893,7 @@ GEM
|
||||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 5.2)
|
||||
semantic_range (>= 2.3.0)
|
||||
webrick (1.8.1)
|
||||
webrick (1.8.2)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
@@ -937,7 +936,6 @@ DEPENDENCIES
|
||||
discard (~> 1.2)
|
||||
doorkeeper (~> 5.6)
|
||||
dotenv
|
||||
ed25519 (~> 1.3)
|
||||
email_spec
|
||||
fabrication (~> 2.30)
|
||||
faker (~> 3.2)
|
||||
|
||||
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.2.x | Yes |
|
||||
| 4.1.x | Yes |
|
||||
| < 4.1 | No |
|
||||
| Version | Supported |
|
||||
| ------- | ---------------- |
|
||||
| 4.3.x | Yes |
|
||||
| 4.2.x | Yes |
|
||||
| 4.1.x | Until 2025-04-08 |
|
||||
| < 4.1 | No |
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::ClaimsController < ActivityPub::BaseController
|
||||
skip_before_action :authenticate_user!
|
||||
|
||||
before_action :require_account_signature!
|
||||
before_action :set_claim_result
|
||||
|
||||
def create
|
||||
render json: @claim_result, serializer: ActivityPub::OneTimeKeySerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_claim_result
|
||||
@claim_result = ::Keys::ClaimService.new.call(@account.id, params[:id])
|
||||
end
|
||||
end
|
||||
@@ -22,8 +22,6 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
@items = @items.map { |item| item.distributable? ? item : ActivityPub::TagManager.instance.uri_for(item) }
|
||||
when 'tags'
|
||||
@items = for_signed_account { @account.featured_tags }
|
||||
when 'devices'
|
||||
@items = @account.devices
|
||||
else
|
||||
not_found
|
||||
end
|
||||
@@ -31,7 +29,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
|
||||
def set_size
|
||||
case params[:id]
|
||||
when 'featured', 'devices', 'tags'
|
||||
when 'featured', 'tags'
|
||||
@size = @items.size
|
||||
else
|
||||
not_found
|
||||
@@ -42,7 +40,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
case params[:id]
|
||||
when 'featured'
|
||||
@type = :ordered
|
||||
when 'devices', 'tags'
|
||||
when 'tags'
|
||||
@type = :unordered
|
||||
else
|
||||
not_found
|
||||
|
||||
36
app/controllers/activitypub/likes_controller.rb
Normal file
36
app/controllers/activitypub/likes_controller.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::LikesController < ActivityPub::BaseController
|
||||
include Authorization
|
||||
|
||||
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||
|
||||
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||
before_action :set_status
|
||||
|
||||
def index
|
||||
expires_in 0, public: @status.distributable? && public_fetch_mode?
|
||||
render json: likes_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pundit_user
|
||||
signed_request_account
|
||||
end
|
||||
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
def likes_collection_presenter
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_status_likes_url(@account, @status),
|
||||
type: :unordered,
|
||||
size: @status.favourites_count
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -12,7 +12,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
||||
before_action :set_replies
|
||||
|
||||
def index
|
||||
expires_in 0, public: public_fetch_mode?
|
||||
expires_in 0, public: @status.distributable? && public_fetch_mode?
|
||||
render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true
|
||||
end
|
||||
|
||||
|
||||
36
app/controllers/activitypub/shares_controller.rb
Normal file
36
app/controllers/activitypub/shares_controller.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::SharesController < ActivityPub::BaseController
|
||||
include Authorization
|
||||
|
||||
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||
|
||||
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||
before_action :set_status
|
||||
|
||||
def index
|
||||
expires_in 0, public: @status.distributable? && public_fetch_mode?
|
||||
render json: shares_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pundit_user
|
||||
signed_request_account
|
||||
end
|
||||
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
def shares_collection_presenter
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_status_shares_url(@account, @status),
|
||||
type: :unordered,
|
||||
size: @status.reblogs_count
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,7 @@ class Admin::AnnouncementsController < Admin::BaseController
|
||||
|
||||
def index
|
||||
authorize :announcement, :index?
|
||||
@published_announcements_count = Announcement.published.async_count
|
||||
end
|
||||
|
||||
def new
|
||||
|
||||
@@ -6,6 +6,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController
|
||||
def index
|
||||
authorize :appeal, :index?
|
||||
|
||||
@pending_appeals_count = Appeal.pending.async_count
|
||||
@appeals = filtered_appeals.page(params[:page])
|
||||
end
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll
|
||||
def index
|
||||
authorize :preview_card_provider, :review?
|
||||
|
||||
@pending_preview_card_providers_count = PreviewCardProvider.unreviewed.async_count
|
||||
@preview_card_providers = filtered_preview_card_providers.page(params[:page])
|
||||
@form = Trends::PreviewCardProviderBatch.new
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ class Admin::Trends::TagsController < Admin::BaseController
|
||||
def index
|
||||
authorize :tag, :review?
|
||||
|
||||
@pending_tags_count = Tag.pending_review.async_count
|
||||
@tags = filtered_tags.page(params[:page])
|
||||
@form = Trends::TagBatch.new
|
||||
end
|
||||
|
||||
@@ -7,7 +7,7 @@ class Api::OEmbedController < Api::BaseController
|
||||
before_action :require_public_status!
|
||||
|
||||
def show
|
||||
render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
|
||||
render json: @status, serializer: OEmbedSerializer, width: params[:maxwidth], height: params[:maxheight]
|
||||
end
|
||||
|
||||
private
|
||||
@@ -23,12 +23,4 @@ class Api::OEmbedController < Api::BaseController
|
||||
def status_finder
|
||||
StatusFinder.new(params[:url])
|
||||
end
|
||||
|
||||
def maxwidth_or_default
|
||||
(params[:maxwidth].presence || 400).to_i
|
||||
end
|
||||
|
||||
def maxheight_or_default
|
||||
params[:maxheight].present? ? params[:maxheight].to_i : nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,6 +16,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
before_action :check_account_confirmation, except: [:index, :create]
|
||||
before_action :check_enabled_registrations, only: [:create]
|
||||
before_action :check_accounts_limit, only: [:index]
|
||||
before_action :check_following_self, only: [:follow]
|
||||
|
||||
skip_before_action :require_authenticated_user!, only: :create
|
||||
|
||||
@@ -101,6 +102,10 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
raise(Mastodon::ValidationError) if account_ids.size > DEFAULT_ACCOUNTS_LIMIT
|
||||
end
|
||||
|
||||
def check_following_self
|
||||
render json: { error: I18n.t('accounts.self_follow_error') }, status: 403 if current_user.account.id == @account.id
|
||||
end
|
||||
|
||||
def relationships(**options)
|
||||
AccountRelationshipsPresenter.new([@account], current_user.account_id, **options)
|
||||
end
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Crypto::DeliveriesController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :crypto }
|
||||
before_action :require_user!
|
||||
before_action :set_current_device
|
||||
|
||||
def create
|
||||
devices.each do |device_params|
|
||||
DeliverToDeviceService.new.call(current_account, @current_device, device_params)
|
||||
end
|
||||
|
||||
render_empty
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_current_device
|
||||
@current_device = Device.find_by!(access_token: doorkeeper_token)
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.require(:device)
|
||||
params.permit(device: [:account_id, :device_id, :type, :body, :hmac])
|
||||
end
|
||||
|
||||
def devices
|
||||
Array(resource_params[:device])
|
||||
end
|
||||
end
|
||||
@@ -1,47 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController
|
||||
LIMIT = 80
|
||||
|
||||
before_action -> { doorkeeper_authorize! :crypto }
|
||||
before_action :require_user!
|
||||
before_action :set_current_device
|
||||
|
||||
before_action :set_encrypted_messages, only: :index
|
||||
after_action :insert_pagination_headers, only: :index
|
||||
|
||||
def index
|
||||
render json: @encrypted_messages, each_serializer: REST::EncryptedMessageSerializer
|
||||
end
|
||||
|
||||
def clear
|
||||
@current_device.encrypted_messages.up_to(params[:up_to_id]).delete_all
|
||||
render_empty
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_current_device
|
||||
@current_device = Device.find_by!(access_token: doorkeeper_token)
|
||||
end
|
||||
|
||||
def set_encrypted_messages
|
||||
@encrypted_messages = @current_device.encrypted_messages.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
|
||||
end
|
||||
|
||||
def next_path
|
||||
api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue?
|
||||
end
|
||||
|
||||
def prev_path
|
||||
api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty?
|
||||
end
|
||||
|
||||
def pagination_collection
|
||||
@encrypted_messages
|
||||
end
|
||||
|
||||
def records_continue?
|
||||
@encrypted_messages.size == limit_param(LIMIT)
|
||||
end
|
||||
end
|
||||
@@ -1,25 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :crypto }
|
||||
before_action :require_user!
|
||||
before_action :set_claim_results
|
||||
|
||||
def create
|
||||
render json: @claim_results, each_serializer: REST::Keys::ClaimResultSerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_claim_results
|
||||
@claim_results = devices.filter_map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) }
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.permit(device: [:account_id, :device_id])
|
||||
end
|
||||
|
||||
def devices
|
||||
Array(resource_params[:device])
|
||||
end
|
||||
end
|
||||
@@ -1,17 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Crypto::Keys::CountsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :crypto }
|
||||
before_action :require_user!
|
||||
before_action :set_current_device
|
||||
|
||||
def show
|
||||
render json: { one_time_keys: @current_device.one_time_keys.count }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_current_device
|
||||
@current_device = Device.find_by!(access_token: doorkeeper_token)
|
||||
end
|
||||
end
|
||||
@@ -1,26 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Crypto::Keys::QueriesController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :crypto }
|
||||
before_action :require_user!
|
||||
before_action :set_accounts
|
||||
before_action :set_query_results
|
||||
|
||||
def create
|
||||
render json: @query_results, each_serializer: REST::Keys::QueryResultSerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_accounts
|
||||
@accounts = Account.where(id: account_ids).includes(:devices)
|
||||
end
|
||||
|
||||
def set_query_results
|
||||
@query_results = @accounts.filter_map { |account| ::Keys::QueryService.new.call(account) }
|
||||
end
|
||||
|
||||
def account_ids
|
||||
Array(params[:id]).map(&:to_i)
|
||||
end
|
||||
end
|
||||
@@ -1,29 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Crypto::Keys::UploadsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :crypto }
|
||||
before_action :require_user!
|
||||
|
||||
def create
|
||||
device = Device.find_or_initialize_by(access_token: doorkeeper_token)
|
||||
|
||||
device.transaction do
|
||||
device.account = current_account
|
||||
device.update!(resource_params[:device])
|
||||
|
||||
if resource_params[:one_time_keys].present? && resource_params[:one_time_keys].is_a?(Enumerable)
|
||||
resource_params[:one_time_keys].each do |one_time_key_params|
|
||||
device.one_time_keys.create!(one_time_key_params)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
render json: device, serializer: REST::Keys::DeviceSerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resource_params
|
||||
params.permit(device: [:device_id, :name, :fingerprint_key, :identity_key], one_time_keys: [:key_id, :key, :signature])
|
||||
end
|
||||
end
|
||||
27
app/controllers/api/v1/domain_blocks/previews_controller.rb
Normal file
27
app/controllers/api/v1/domain_blocks/previews_controller.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::DomainBlocks::PreviewsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :follow, :write, :'write:blocks' }
|
||||
before_action :require_user!
|
||||
before_action :set_domain
|
||||
before_action :set_domain_block_preview
|
||||
|
||||
def show
|
||||
render json: @domain_block_preview, serializer: REST::DomainBlockPreviewSerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_domain
|
||||
@domain = TagManager.instance.normalize_domain(params[:domain])
|
||||
end
|
||||
|
||||
def set_domain_block_preview
|
||||
@domain_block_preview = with_read_replica do
|
||||
DomainBlockPreviewPresenter.new(
|
||||
following_count: current_account.following.where(domain: @domain).count,
|
||||
followers_count: current_account.followers.where(domain: @domain).count
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -52,7 +52,7 @@ class Api::V1::Notifications::RequestsController < Api::BaseController
|
||||
private
|
||||
|
||||
def load_requests
|
||||
requests = NotificationRequest.where(account: current_account).includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id(
|
||||
requests = NotificationRequest.where(account: current_account).without_suspended.includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id(
|
||||
limit_param(DEFAULT_ACCOUNTS_LIMIT),
|
||||
params_slice(:max_id, :since_id, :min_id)
|
||||
)
|
||||
|
||||
@@ -7,6 +7,8 @@ class Api::V1::Peers::SearchController < Api::BaseController
|
||||
skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
|
||||
skip_around_action :set_locale
|
||||
|
||||
LIMIT = 10
|
||||
|
||||
vary_by ''
|
||||
|
||||
def index
|
||||
@@ -35,10 +37,10 @@ class Api::V1::Peers::SearchController < Api::BaseController
|
||||
field: 'accounts_count',
|
||||
modifier: 'log2p',
|
||||
},
|
||||
}).limit(10).pluck(:domain)
|
||||
}).limit(LIMIT).pluck(:domain)
|
||||
else
|
||||
domain = normalized_domain
|
||||
@domains = Instance.searchable.domain_starts_with(domain).limit(10).pluck(:domain)
|
||||
@domains = Instance.searchable.domain_starts_with(domain).limit(LIMIT).pluck(:domain)
|
||||
end
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
@domains = []
|
||||
|
||||
@@ -23,6 +23,6 @@ class Api::V1::Statuses::TranslationsController < Api::V1::Statuses::BaseControl
|
||||
private
|
||||
|
||||
def set_translation
|
||||
@translation = TranslateStatusService.new.call(@status, content_locale)
|
||||
@translation = TranslateStatusService.new.call(@status, I18n.locale.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,7 +9,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController
|
||||
return not_found if @status.hidden?
|
||||
|
||||
if @status.local?
|
||||
render json: @status, serializer: OEmbedSerializer, width: 400
|
||||
render json: @status, serializer: OEmbedSerializer
|
||||
else
|
||||
return not_found unless user_signed_in?
|
||||
|
||||
|
||||
@@ -20,11 +20,6 @@ class Auth::SessionsController < Devise::SessionsController
|
||||
p.form_action(false)
|
||||
end
|
||||
|
||||
def check_suspicious!
|
||||
user = find_user
|
||||
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
||||
end
|
||||
|
||||
def create
|
||||
super do |resource|
|
||||
# We only need to call this if this hasn't already been
|
||||
@@ -101,6 +96,11 @@ class Auth::SessionsController < Devise::SessionsController
|
||||
|
||||
private
|
||||
|
||||
def check_suspicious!
|
||||
user = find_user
|
||||
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
||||
end
|
||||
|
||||
def home_paths(resource)
|
||||
paths = [about_path, '/explore']
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ module WebAppControllerConcern
|
||||
policy = ContentSecurityPolicy.new
|
||||
|
||||
if policy.sso_host.present?
|
||||
p.form_action policy.sso_host
|
||||
p.form_action policy.sso_host, -> { "https://#{request.host}/auth/auth/" }
|
||||
else
|
||||
p.form_action :none
|
||||
end
|
||||
@@ -31,7 +31,7 @@ module WebAppControllerConcern
|
||||
def redirect_unauthenticated_to_permalinks!
|
||||
return if user_signed_in? # NOTE: Different from upstream because we allow moved users to log in
|
||||
|
||||
permalink_redirector = PermalinkRedirector.new(request.path)
|
||||
permalink_redirector = PermalinkRedirector.new(request.original_fullpath)
|
||||
return if permalink_redirector.redirect_path.blank?
|
||||
|
||||
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?
|
||||
|
||||
@@ -15,7 +15,7 @@ module Settings
|
||||
end
|
||||
|
||||
def create
|
||||
session[:new_otp_secret] = User.generate_otp_secret(32)
|
||||
session[:new_otp_secret] = User.generate_otp_secret
|
||||
|
||||
redirect_to new_settings_two_factor_authentication_confirmation_path
|
||||
end
|
||||
|
||||
@@ -7,7 +7,23 @@ module WellKnown
|
||||
def show
|
||||
@webfinger_template = "#{webfinger_url}?resource={uri}"
|
||||
expires_in 3.days, public: true
|
||||
render content_type: 'application/xrd+xml', formats: [:xml]
|
||||
|
||||
respond_to do |format|
|
||||
format.any do
|
||||
render content_type: 'application/xrd+xml', formats: [:xml]
|
||||
end
|
||||
|
||||
format.json do
|
||||
render json: {
|
||||
links: [
|
||||
{
|
||||
rel: 'lrdd',
|
||||
template: @webfinger_template,
|
||||
},
|
||||
],
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,4 +35,11 @@ module Admin::ActionLogsHelper
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sorted_action_log_types
|
||||
Admin::ActionLogFilter::ACTION_TYPE_MAP
|
||||
.keys
|
||||
.map { |key| [I18n.t("admin.action_logs.action_types.#{key}"), key] }
|
||||
.sort_by(&:first)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,6 +18,11 @@ module Admin::DashboardHelper
|
||||
end
|
||||
end
|
||||
|
||||
def date_range(range)
|
||||
[l(range.first), l(range.last)]
|
||||
.join(' - ')
|
||||
end
|
||||
|
||||
def relevant_account_timestamp(account)
|
||||
timestamp, exact = if account.user_current_sign_in_at && account.user_current_sign_in_at < 24.hours.ago
|
||||
[account.user_current_sign_in_at, true]
|
||||
@@ -25,6 +30,8 @@ module Admin::DashboardHelper
|
||||
[account.user_current_sign_in_at, false]
|
||||
elsif account.user_pending?
|
||||
[account.user_created_at, true]
|
||||
elsif account.suspended_at.present? && account.local? && account.user.nil?
|
||||
[account.suspended_at, true]
|
||||
elsif account.last_status_at.present?
|
||||
[account.last_status_at, true]
|
||||
else
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ApplicationHelper
|
||||
DANGEROUS_SCOPES = %w(
|
||||
read
|
||||
write
|
||||
follow
|
||||
).freeze
|
||||
|
||||
RTL_LOCALES = %i(
|
||||
ar
|
||||
ckb
|
||||
@@ -85,7 +79,7 @@ module ApplicationHelper
|
||||
|
||||
def html_title
|
||||
safe_join(
|
||||
[content_for(:page_title).to_s.chomp, title]
|
||||
[content_for(:page_title), title]
|
||||
.compact_blank,
|
||||
' - '
|
||||
)
|
||||
@@ -95,8 +89,11 @@ module ApplicationHelper
|
||||
Rails.env.production? ? site_title : "#{site_title} (Dev)"
|
||||
end
|
||||
|
||||
def class_for_scope(scope)
|
||||
'scope-danger' if DANGEROUS_SCOPES.include?(scope.to_s)
|
||||
def label_for_scope(scope)
|
||||
safe_join [
|
||||
tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }),
|
||||
tag.span(t("doorkeeper.scopes.#{scope}"), class: :hint),
|
||||
]
|
||||
end
|
||||
|
||||
def can?(action, record)
|
||||
@@ -244,6 +241,15 @@ module ApplicationHelper
|
||||
full_asset_url(instance_presenter.mascot&.file&.url || frontend_asset_path('images/elephant_ui_plane.svg'))
|
||||
end
|
||||
|
||||
def copyable_input(options = {})
|
||||
tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options)
|
||||
end
|
||||
|
||||
def recent_tag_usage(tag)
|
||||
people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts
|
||||
I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people
|
||||
end
|
||||
|
||||
# glitch-soc addition to handle the multiple flavors
|
||||
def preload_locale_pack
|
||||
supported_locales = Themes.instance.flavour(current_flavour)['locales']
|
||||
|
||||
@@ -24,23 +24,6 @@ module ContextHelper
|
||||
indexable: { 'toot' => 'http://joinmastodon.org/ns#', 'indexable' => 'toot:indexable' },
|
||||
memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' },
|
||||
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
||||
olm: {
|
||||
'toot' => 'http://joinmastodon.org/ns#',
|
||||
'Device' => 'toot:Device',
|
||||
'Ed25519Signature' => 'toot:Ed25519Signature',
|
||||
'Ed25519Key' => 'toot:Ed25519Key',
|
||||
'Curve25519Key' => 'toot:Curve25519Key',
|
||||
'EncryptedMessage' => 'toot:EncryptedMessage',
|
||||
'publicKeyBase64' => 'toot:publicKeyBase64',
|
||||
'deviceId' => 'toot:deviceId',
|
||||
'claim' => { '@type' => '@id', '@id' => 'toot:claim' },
|
||||
'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' },
|
||||
'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' },
|
||||
'devices' => { '@type' => '@id', '@id' => 'toot:devices' },
|
||||
'messageFranking' => 'toot:messageFranking',
|
||||
'messageType' => 'toot:messageType',
|
||||
'cipherText' => 'toot:cipherText',
|
||||
},
|
||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
||||
}.freeze
|
||||
|
||||
@@ -162,7 +162,7 @@ module LanguagesHelper
|
||||
th: ['Thai', 'ไทย'].freeze,
|
||||
ti: ['Tigrinya', 'ትግርኛ'].freeze,
|
||||
tk: ['Turkmen', 'Türkmen'].freeze,
|
||||
tl: ['Tagalog', 'Wikang Tagalog'].freeze,
|
||||
tl: ['Tagalog', 'Tagalog'].freeze,
|
||||
tn: ['Tswana', 'Setswana'].freeze,
|
||||
to: ['Tonga', 'faka Tonga'].freeze,
|
||||
tr: ['Turkish', 'Türkçe'].freeze,
|
||||
|
||||
@@ -10,16 +10,17 @@ module SettingsHelper
|
||||
end
|
||||
|
||||
def featured_tags_hint(recently_used_tags)
|
||||
safe_join(
|
||||
[
|
||||
t('simple_form.hints.featured_tag.name'),
|
||||
safe_join(
|
||||
links_for_featured_tags(recently_used_tags),
|
||||
', '
|
||||
),
|
||||
],
|
||||
' '
|
||||
)
|
||||
recently_used_tags.present? &&
|
||||
safe_join(
|
||||
[
|
||||
t('simple_form.hints.featured_tag.name'),
|
||||
safe_join(
|
||||
links_for_featured_tags(recently_used_tags),
|
||||
', '
|
||||
),
|
||||
],
|
||||
' '
|
||||
)
|
||||
end
|
||||
|
||||
def session_device_icon(session)
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module WebfingerHelper
|
||||
def webfinger!(uri)
|
||||
Webfinger.new(uri).perform
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,5 @@
|
||||
import { browserHistory } from 'flavours/glitch/components/router';
|
||||
import { debounceWithDispatchAndArguments } from 'flavours/glitch/utils/debounce';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
@@ -462,6 +463,20 @@ export function expandFollowingFail(id, error) {
|
||||
};
|
||||
}
|
||||
|
||||
const debouncedFetchRelationships = debounceWithDispatchAndArguments((dispatch, ...newAccountIds) => {
|
||||
if (newAccountIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchRelationshipsRequest(newAccountIds));
|
||||
|
||||
api().get(`/api/v1/accounts/relationships?with_suspended=true&${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
|
||||
dispatch(fetchRelationshipsSuccess({ relationships: response.data }));
|
||||
}).catch(error => {
|
||||
dispatch(fetchRelationshipsFail(error));
|
||||
});
|
||||
}, { delay: 500 });
|
||||
|
||||
export function fetchRelationships(accountIds) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
@@ -473,13 +488,7 @@ export function fetchRelationships(accountIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchRelationshipsRequest(newAccountIds));
|
||||
|
||||
api().get(`/api/v1/accounts/relationships?with_suspended=true&${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
|
||||
dispatch(fetchRelationshipsSuccess({ relationships: response.data }));
|
||||
}).catch(error => {
|
||||
dispatch(fetchRelationshipsFail(error));
|
||||
});
|
||||
debouncedFetchRelationships(dispatch, ...newAccountIds);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
const messages = defineMessages({
|
||||
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
||||
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
|
||||
@@ -50,6 +52,11 @@ export const showAlertForError = (error, skipNotFound = false) => {
|
||||
});
|
||||
}
|
||||
|
||||
// An aborted request, e.g. due to reloading the browser window, it not really error
|
||||
if (error.code === AxiosError.ECONNABORTED) {
|
||||
return { type: ALERT_NOOP };
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
|
||||
return showAlert({
|
||||
|
||||
@@ -37,8 +37,7 @@ export const synchronouslySubmitMarkers = createAppAsyncThunk(
|
||||
});
|
||||
|
||||
return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
} else if ('navigator' && 'sendBeacon' in navigator) {
|
||||
} else if ('sendBeacon' in navigator) {
|
||||
// Failing that, we can use sendBeacon, but we have to encode the data as
|
||||
// FormData for DoorKeeper to recognize the token.
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
|
||||
import type {
|
||||
ApiNotificationGroupJSON,
|
||||
ApiNotificationJSON,
|
||||
NotificationType,
|
||||
} from 'flavours/glitch/api_types/notifications';
|
||||
import { allNotificationTypes } from 'flavours/glitch/api_types/notifications';
|
||||
import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses';
|
||||
@@ -15,6 +16,7 @@ import { usePendingItems } from 'flavours/glitch/initial_state';
|
||||
import type { NotificationGap } from 'flavours/glitch/reducers/notification_groups';
|
||||
import {
|
||||
selectSettingsNotificationsExcludedTypes,
|
||||
selectSettingsNotificationsGroupFollows,
|
||||
selectSettingsNotificationsQuickFilterActive,
|
||||
selectSettingsNotificationsShows,
|
||||
} from 'flavours/glitch/selectors/settings';
|
||||
@@ -68,10 +70,21 @@ function dispatchAssociatedRecords(
|
||||
dispatch(importFetchedStatuses(fetchedStatuses));
|
||||
}
|
||||
|
||||
function selectNotificationGroupedTypes(state: RootState) {
|
||||
const types: NotificationType[] = ['favourite', 'reblog'];
|
||||
|
||||
if (selectSettingsNotificationsGroupFollows(state)) types.push('follow');
|
||||
|
||||
return types;
|
||||
}
|
||||
|
||||
export const fetchNotifications = createDataLoadingThunk(
|
||||
'notificationGroups/fetch',
|
||||
async (_params, { getState }) =>
|
||||
apiFetchNotificationGroups({ exclude_types: getExcludedTypes(getState()) }),
|
||||
apiFetchNotificationGroups({
|
||||
grouped_types: selectNotificationGroupedTypes(getState()),
|
||||
exclude_types: getExcludedTypes(getState()),
|
||||
}),
|
||||
({ notifications, accounts, statuses }, { dispatch }) => {
|
||||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch(importFetchedStatuses(statuses));
|
||||
@@ -93,6 +106,7 @@ export const fetchNotificationsGap = createDataLoadingThunk(
|
||||
'notificationGroups/fetchGap',
|
||||
async (params: { gap: NotificationGap }, { getState }) =>
|
||||
apiFetchNotificationGroups({
|
||||
grouped_types: selectNotificationGroupedTypes(getState()),
|
||||
max_id: params.gap.maxId,
|
||||
exclude_types: getExcludedTypes(getState()),
|
||||
}),
|
||||
@@ -109,6 +123,7 @@ export const pollRecentNotifications = createDataLoadingThunk(
|
||||
'notificationGroups/pollRecentNotifications',
|
||||
async (_params, { getState }) => {
|
||||
return apiFetchNotificationGroups({
|
||||
grouped_types: selectNotificationGroupedTypes(getState()),
|
||||
max_id: undefined,
|
||||
exclude_types: getExcludedTypes(getState()),
|
||||
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones
|
||||
@@ -157,7 +172,10 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
|
||||
|
||||
dispatchAssociatedRecords(dispatch, [notification]);
|
||||
|
||||
return notification;
|
||||
return {
|
||||
notification,
|
||||
groupedTypes: selectNotificationGroupedTypes(state),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,6 @@ export const updateNotificationsPolicy = createDataLoadingThunk(
|
||||
(policy: Partial<NotificationPolicy>) => apiUpdateNotificationsPolicy(policy),
|
||||
);
|
||||
|
||||
export const decreasePendingNotificationsCount = createAction<number>(
|
||||
'notificationPolicy/decreasePendingNotificationCount',
|
||||
export const decreasePendingRequestsCount = createAction<number>(
|
||||
'notificationPolicy/decreasePendingRequestsCount',
|
||||
);
|
||||
|
||||
@@ -13,11 +13,11 @@ import type {
|
||||
ApiNotificationJSON,
|
||||
} from 'flavours/glitch/api_types/notifications';
|
||||
import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses';
|
||||
import type { AppDispatch, RootState } from 'flavours/glitch/store';
|
||||
import type { AppDispatch } from 'flavours/glitch/store';
|
||||
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
import { decreasePendingNotificationsCount } from './notification_policies';
|
||||
import { decreasePendingRequestsCount } from './notification_policies';
|
||||
|
||||
// TODO: refactor with notification_groups
|
||||
function dispatchAssociatedRecords(
|
||||
@@ -169,19 +169,11 @@ export const expandNotificationsForRequest = createDataLoadingThunk(
|
||||
},
|
||||
);
|
||||
|
||||
const selectNotificationCountForRequest = (state: RootState, id: string) => {
|
||||
const requests = state.notificationRequests.items;
|
||||
const thisRequest = requests.find((request) => request.id === id);
|
||||
return thisRequest ? thisRequest.notifications_count : 0;
|
||||
};
|
||||
|
||||
export const acceptNotificationRequest = createDataLoadingThunk(
|
||||
'notificationRequest/accept',
|
||||
({ id }: { id: string }) => apiAcceptNotificationRequest(id),
|
||||
(_data, { dispatch, getState, discardLoadData, actionArg: { id } }) => {
|
||||
const count = selectNotificationCountForRequest(getState(), id);
|
||||
|
||||
dispatch(decreasePendingNotificationsCount(count));
|
||||
(_data, { dispatch, discardLoadData }) => {
|
||||
dispatch(decreasePendingRequestsCount(1));
|
||||
|
||||
// The payload is not used in any functions
|
||||
return discardLoadData;
|
||||
@@ -191,10 +183,8 @@ export const acceptNotificationRequest = createDataLoadingThunk(
|
||||
export const dismissNotificationRequest = createDataLoadingThunk(
|
||||
'notificationRequest/dismiss',
|
||||
({ id }: { id: string }) => apiDismissNotificationRequest(id),
|
||||
(_data, { dispatch, getState, discardLoadData, actionArg: { id } }) => {
|
||||
const count = selectNotificationCountForRequest(getState(), id);
|
||||
|
||||
dispatch(decreasePendingNotificationsCount(count));
|
||||
(_data, { dispatch, discardLoadData }) => {
|
||||
dispatch(decreasePendingRequestsCount(1));
|
||||
|
||||
// The payload is not used in any functions
|
||||
return discardLoadData;
|
||||
@@ -204,13 +194,8 @@ export const dismissNotificationRequest = createDataLoadingThunk(
|
||||
export const acceptNotificationRequests = createDataLoadingThunk(
|
||||
'notificationRequests/acceptBulk',
|
||||
({ ids }: { ids: string[] }) => apiAcceptNotificationRequests(ids),
|
||||
(_data, { dispatch, getState, discardLoadData, actionArg: { ids } }) => {
|
||||
const count = ids.reduce(
|
||||
(count, id) => count + selectNotificationCountForRequest(getState(), id),
|
||||
0,
|
||||
);
|
||||
|
||||
dispatch(decreasePendingNotificationsCount(count));
|
||||
(_data, { dispatch, discardLoadData, actionArg: { ids } }) => {
|
||||
dispatch(decreasePendingRequestsCount(ids.length));
|
||||
|
||||
// The payload is not used in any functions
|
||||
return discardLoadData;
|
||||
@@ -220,13 +205,8 @@ export const acceptNotificationRequests = createDataLoadingThunk(
|
||||
export const dismissNotificationRequests = createDataLoadingThunk(
|
||||
'notificationRequests/dismissBulk',
|
||||
({ ids }: { ids: string[] }) => apiDismissNotificationRequests(ids),
|
||||
(_data, { dispatch, getState, discardLoadData, actionArg: { ids } }) => {
|
||||
const count = ids.reduce(
|
||||
(count, id) => count + selectNotificationCountForRequest(getState(), id),
|
||||
0,
|
||||
);
|
||||
|
||||
dispatch(decreasePendingNotificationsCount(count));
|
||||
(_data, { dispatch, discardLoadData, actionArg: { ids } }) => {
|
||||
dispatch(decreasePendingRequestsCount(ids.length));
|
||||
|
||||
// The payload is not used in any functions
|
||||
return discardLoadData;
|
||||
|
||||
@@ -10,7 +10,7 @@ import api, { getLinks } from '../api';
|
||||
import { unescapeHTML } from '../utils/html';
|
||||
import { requestNotificationPermission } from '../utils/notifications';
|
||||
|
||||
import { fetchFollowRequests, fetchRelationships } from './accounts';
|
||||
import { fetchFollowRequests } from './accounts';
|
||||
import {
|
||||
importFetchedAccount,
|
||||
importFetchedAccounts,
|
||||
@@ -68,14 +68,6 @@ defineMessages({
|
||||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
||||
});
|
||||
|
||||
const fetchRelatedRelationships = (dispatch, notifications) => {
|
||||
const accountIds = notifications.filter(item => ['follow', 'follow_request', 'admin.sign_up'].indexOf(item.type) !== -1).map(item => item.account.id);
|
||||
|
||||
if (accountIds.length > 0) {
|
||||
dispatch(fetchRelationships(accountIds));
|
||||
}
|
||||
};
|
||||
|
||||
export const loadPending = () => ({
|
||||
type: NOTIFICATIONS_LOAD_PENDING,
|
||||
});
|
||||
@@ -118,8 +110,6 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||
|
||||
|
||||
dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered}));
|
||||
|
||||
fetchRelatedRelationships(dispatch, [notification]);
|
||||
} else if (playSound && !filtered) {
|
||||
dispatch({
|
||||
type: NOTIFICATIONS_UPDATE_NOOP,
|
||||
@@ -211,7 +201,6 @@ export function expandNotifications({ maxId = undefined, forceLoad = false }) {
|
||||
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
|
||||
|
||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
||||
fetchRelatedRelationships(dispatch, response.data);
|
||||
dispatch(submitMarkers());
|
||||
} catch(error) {
|
||||
dispatch(expandNotificationsFail(error, isLoadingMore));
|
||||
|
||||
@@ -42,6 +42,9 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => {
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function api(withAuthorization = true) {
|
||||
return axios.create({
|
||||
transitional: {
|
||||
clarifyTimeoutError: true,
|
||||
},
|
||||
headers: {
|
||||
...csrfHeader,
|
||||
...(withAuthorization ? authorizationTokenFromInitialState() : {}),
|
||||
@@ -67,6 +70,7 @@ export async function apiRequest<ApiResponse = unknown>(
|
||||
args: {
|
||||
params?: RequestParamsOrData;
|
||||
data?: RequestParamsOrData;
|
||||
timeout?: number;
|
||||
} = {},
|
||||
) {
|
||||
const { data } = await api().request<ApiResponse>({
|
||||
|
||||
@@ -31,6 +31,7 @@ export const apiFetchNotifications = async (
|
||||
|
||||
export const apiFetchNotificationGroups = async (params?: {
|
||||
url?: string;
|
||||
grouped_types?: string[];
|
||||
exclude_types?: string[];
|
||||
max_id?: string;
|
||||
since_id?: string;
|
||||
@@ -91,5 +92,5 @@ export const apiAcceptNotificationRequests = async (id: string[]) => {
|
||||
};
|
||||
|
||||
export const apiDismissNotificationRequests = async (id: string[]) => {
|
||||
return apiRequestPost('v1/notifications/dismiss/dismiss', { id });
|
||||
return apiRequestPost('v1/notifications/requests/dismiss', { id });
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface ApiAccountRoleJSON {
|
||||
}
|
||||
|
||||
// See app/serializers/rest/account_serializer.rb
|
||||
export interface ApiAccountJSON {
|
||||
export interface BaseApiAccountJSON {
|
||||
acct: string;
|
||||
avatar: string;
|
||||
avatar_static: string;
|
||||
@@ -45,3 +45,12 @@ export interface ApiAccountJSON {
|
||||
memorial?: boolean;
|
||||
hide_collections: boolean;
|
||||
}
|
||||
|
||||
// See app/serializers/rest/muted_account_serializer.rb
|
||||
export interface ApiMutedAccountJSON extends BaseApiAccountJSON {
|
||||
mute_expires_at?: string | null;
|
||||
}
|
||||
|
||||
// For now, we have the same type representing both `Account` and `MutedAccount`
|
||||
// objects, but we should refactor this in the future.
|
||||
export type ApiAccountJSON = ApiMutedAccountJSON;
|
||||
|
||||
67
app/javascript/flavours/glitch/components/alt_text_badge.tsx
Normal file
67
app/javascript/flavours/glitch/components/alt_text_badge.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
import type {
|
||||
OffsetValue,
|
||||
UsePopperOptions,
|
||||
} from 'react-overlays/esm/usePopper';
|
||||
|
||||
const offset = [0, 4] as OffsetValue;
|
||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||
|
||||
export const AltTextBadge: React.FC<{
|
||||
description: string;
|
||||
}> = ({ description }) => {
|
||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setOpen((v) => !v);
|
||||
}, [setOpen]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
className='media-gallery__alt__label'
|
||||
onClick={handleClick}
|
||||
>
|
||||
ALT
|
||||
</button>
|
||||
|
||||
<Overlay
|
||||
rootClose
|
||||
onHide={handleClose}
|
||||
show={open}
|
||||
target={anchorRef.current}
|
||||
placement='top-end'
|
||||
flip
|
||||
offset={offset}
|
||||
popperConfig={popperConfig}
|
||||
>
|
||||
{({ props }) => (
|
||||
<div {...props} className='hover-card-controller'>
|
||||
<div
|
||||
className='media-gallery__alt__popover dropdown-animation'
|
||||
role='tooltip'
|
||||
>
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id='alt_text_badge.title'
|
||||
defaultMessage='Alt text'
|
||||
/>
|
||||
</h4>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useHovering } from 'flavours/glitch/hooks/useHovering';
|
||||
import { autoPlayGif } from 'flavours/glitch/initial_state';
|
||||
import type { Account } from 'flavours/glitch/models/account';
|
||||
|
||||
import { useHovering } from '../hooks/useHovering';
|
||||
import { autoPlayGif } from '../initial_state';
|
||||
|
||||
interface Props {
|
||||
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
|
||||
size: number;
|
||||
@@ -25,6 +26,8 @@ export const Avatar: React.FC<Props> = ({
|
||||
counterBorderColor,
|
||||
}) => {
|
||||
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const style = {
|
||||
...styleFromParent,
|
||||
@@ -37,17 +40,29 @@ export const Avatar: React.FC<Props> = ({
|
||||
? account?.get('avatar')
|
||||
: account?.get('avatar_static');
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
setLoading(false);
|
||||
}, [setLoading]);
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
setError(true);
|
||||
}, [setError]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('account__avatar', {
|
||||
'account__avatar-inline': inline,
|
||||
'account__avatar--inline': inline,
|
||||
'account__avatar--loading': loading,
|
||||
})}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={style}
|
||||
data-avatar-of={account && `@${account.get('acct')}`}
|
||||
>
|
||||
{src && <img src={src} alt='' />}
|
||||
{src && !error && (
|
||||
<img src={src} alt='' onLoad={handleLoad} onError={handleError} />
|
||||
)}
|
||||
|
||||
{counter && (
|
||||
<div
|
||||
className='account__avatar__counter'
|
||||
|
||||
@@ -7,6 +7,7 @@ interface BaseProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
block?: boolean;
|
||||
secondary?: boolean;
|
||||
dangerous?: boolean;
|
||||
}
|
||||
|
||||
interface PropsChildren extends PropsWithChildren<BaseProps> {
|
||||
@@ -26,6 +27,7 @@ export const Button: React.FC<Props> = ({
|
||||
disabled,
|
||||
block,
|
||||
secondary,
|
||||
dangerous,
|
||||
className,
|
||||
title,
|
||||
text,
|
||||
@@ -46,6 +48,7 @@ export const Button: React.FC<Props> = ({
|
||||
className={classNames('button', className, {
|
||||
'button-secondary': secondary,
|
||||
'button--block': block,
|
||||
'button--dangerous': dangerous,
|
||||
})}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -19,6 +19,7 @@ export const CollapseButton = ({ collapsed, setCollapsed }) => {
|
||||
if (e.button === 0) {
|
||||
setCollapsed(!collapsed);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, [collapsed, setCollapsed]);
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/* Significantly rewritten from upstream to keep the old design for now */
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const ContentWarning: React.FC<{
|
||||
text: string;
|
||||
expanded?: boolean;
|
||||
onClick?: () => void;
|
||||
icons?: React.ReactNode[];
|
||||
}> = ({ text, expanded, onClick, icons }) => (
|
||||
<p>
|
||||
<span dangerouslySetInnerHTML={{ __html: text }} className='translate' />{' '}
|
||||
<button
|
||||
type='button'
|
||||
className='status__content__spoiler-link'
|
||||
onClick={onClick}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? (
|
||||
<FormattedMessage
|
||||
id='content_warning.hide'
|
||||
defaultMessage='Hide post'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='content_warning.show_more'
|
||||
defaultMessage='Show more'
|
||||
/>
|
||||
)}
|
||||
{icons}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
26
app/javascript/flavours/glitch/components/filter_warning.tsx
Normal file
26
app/javascript/flavours/glitch/components/filter_warning.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { StatusBanner, BannerVariant } from './status_banner';
|
||||
|
||||
export const FilterWarning: React.FC<{
|
||||
title: string;
|
||||
expanded?: boolean;
|
||||
onClick?: () => void;
|
||||
}> = ({ title, expanded, onClick }) => (
|
||||
<StatusBanner
|
||||
expanded={expanded}
|
||||
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>
|
||||
</StatusBanner>
|
||||
);
|
||||
@@ -10,7 +10,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { AltTextBadge } from 'flavours/glitch/components/alt_text_badge';
|
||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||
import { formatTime } from 'flavours/glitch/features/video';
|
||||
|
||||
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
|
||||
|
||||
@@ -58,7 +60,7 @@ class Item extends PureComponent {
|
||||
|
||||
hoverToPlay () {
|
||||
const { attachment } = this.props;
|
||||
return !this.getAutoPlay() && attachment.get('type') === 'gifv';
|
||||
return !this.getAutoPlay() && ['gifv', 'video'].includes(attachment.get('type'));
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
@@ -96,12 +98,12 @@ class Item extends PureComponent {
|
||||
height = 50;
|
||||
}
|
||||
|
||||
if (attachment.get('description')?.length > 0) {
|
||||
badges.push(<span key='alt' className='media-gallery__alt__label'>ALT</span>);
|
||||
}
|
||||
|
||||
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
||||
|
||||
if (description?.length > 0) {
|
||||
badges.push(<AltTextBadge key='alt' description={description} />);
|
||||
}
|
||||
|
||||
if (attachment.get('type') === 'unknown') {
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
|
||||
@@ -152,10 +154,15 @@ class Item extends PureComponent {
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
} else if (attachment.get('type') === 'gifv') {
|
||||
} else if (['gifv', 'video'].includes(attachment.get('type'))) {
|
||||
const autoPlay = this.getAutoPlay();
|
||||
const duration = attachment.getIn(['meta', 'original', 'duration']);
|
||||
|
||||
badges.push(<span key='gif' className='media-gallery__gifv__label'>GIF</span>);
|
||||
if (attachment.get('type') === 'gifv') {
|
||||
badges.push(<span key='gif' className='media-gallery__alt__label media-gallery__alt__label--non-interactive'>GIF</span>);
|
||||
} else {
|
||||
badges.push(<span key='video' className='media-gallery__alt__label media-gallery__alt__label--non-interactive'>{formatTime(Math.floor(duration))}</span>);
|
||||
}
|
||||
|
||||
thumbnail = (
|
||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
||||
@@ -169,6 +176,7 @@ class Item extends PureComponent {
|
||||
onClick={this.handleClick}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
onLoadedData={this.handleImageLoad}
|
||||
autoPlay={autoPlay}
|
||||
playsInline
|
||||
loop
|
||||
@@ -190,7 +198,7 @@ class Item extends PureComponent {
|
||||
|
||||
{visible && thumbnail}
|
||||
|
||||
{badges && (
|
||||
{visible && badges && (
|
||||
<div className='media-gallery__item__badges'>
|
||||
{badges}
|
||||
</div>
|
||||
@@ -348,14 +356,14 @@ class MediaGallery extends PureComponent {
|
||||
|
||||
return (
|
||||
<div className={computedClass} style={style} ref={this.handleRef}>
|
||||
{children}
|
||||
|
||||
{(!visible || uncached) && (
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
|
||||
{spoilerButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{(visible && !uncached) && (
|
||||
<div className='media-gallery__actions'>
|
||||
<button className='media-gallery__actions__pill' onClick={this.handleOpen}><FormattedMessage id='media_gallery.hide' defaultMessage='Hide' /></button>
|
||||
|
||||
@@ -153,7 +153,7 @@ class ModalRoot extends PureComponent {
|
||||
return (
|
||||
<div className='modal-root' ref={this.setRef}>
|
||||
<div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
|
||||
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.7)` : null }} />
|
||||
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.9)` : null }} />
|
||||
<div role='dialog' className='modal-root__container'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,22 +4,22 @@ import AccountNavigation from 'flavours/glitch/features/account/navigation';
|
||||
import Trends from 'flavours/glitch/features/getting_started/containers/trends_container';
|
||||
import { showTrends } from 'flavours/glitch/initial_state';
|
||||
|
||||
const DefaultNavigation: React.FC = () =>
|
||||
showTrends ? (
|
||||
<>
|
||||
<div className='flex-spacer' />
|
||||
<Trends />
|
||||
</>
|
||||
) : null;
|
||||
const DefaultNavigation: React.FC = () => (showTrends ? <Trends /> : null);
|
||||
|
||||
export const NavigationPortal: React.FC = () => (
|
||||
<Switch>
|
||||
<Route path='/@:acct' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/tagged/:tagged?' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/with_replies' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/followers' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/following' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/media' exact component={AccountNavigation} />
|
||||
<Route component={DefaultNavigation} />
|
||||
</Switch>
|
||||
<div className='navigation-panel__portal'>
|
||||
<Switch>
|
||||
<Route path='/@:acct' exact component={AccountNavigation} />
|
||||
<Route
|
||||
path='/@:acct/tagged/:tagged?'
|
||||
exact
|
||||
component={AccountNavigation}
|
||||
/>
|
||||
<Route path='/@:acct/with_replies' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/followers' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/following' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/media' exact component={AccountNavigation} />
|
||||
<Route component={DefaultNavigation} />
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -51,7 +51,8 @@ function normalizePath(
|
||||
|
||||
if (
|
||||
layoutFromWindow() === 'multi-column' &&
|
||||
!location.pathname?.startsWith('/deck')
|
||||
location.pathname &&
|
||||
!location.pathname.startsWith('/deck')
|
||||
) {
|
||||
location.pathname = `/deck${location.pathname}`;
|
||||
}
|
||||
|
||||
@@ -372,26 +372,29 @@ class Status extends ImmutablePureComponent {
|
||||
const { isCollapsed } = this.state;
|
||||
if (!history) return;
|
||||
|
||||
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
|
||||
if (isCollapsed) this.setCollapsed(false);
|
||||
else if (e.shiftKey) {
|
||||
this.setCollapsed(true);
|
||||
document.getSelection().removeAllRanges();
|
||||
} else if (this.props.onClick) {
|
||||
this.props.onClick();
|
||||
return;
|
||||
} else {
|
||||
if (destination === undefined) {
|
||||
destination = `/@${
|
||||
status.getIn(['reblog', 'account', 'acct'], status.getIn(['account', 'acct']))
|
||||
}/${
|
||||
status.getIn(['reblog', 'id'], status.get('id'))
|
||||
}`;
|
||||
}
|
||||
history.push(destination);
|
||||
}
|
||||
e.preventDefault();
|
||||
if (e.button !== 0 || e.ctrlKey || e.altKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCollapsed) this.setCollapsed(false);
|
||||
else if (e.shiftKey) {
|
||||
this.setCollapsed(true);
|
||||
document.getSelection().removeAllRanges();
|
||||
} else if (this.props.onClick) {
|
||||
this.props.onClick();
|
||||
return;
|
||||
} else {
|
||||
if (destination === undefined) {
|
||||
destination = `/@${
|
||||
status.getIn(['reblog', 'account', 'acct'], status.getIn(['account', 'acct']))
|
||||
}/${
|
||||
status.getIn(['reblog', 'id'], status.get('id'))
|
||||
}`;
|
||||
}
|
||||
history.push(destination);
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
handleToggleMediaVisibility = () => {
|
||||
@@ -648,6 +651,27 @@ class Status extends ImmutablePureComponent {
|
||||
media={status.get('media_attachments')}
|
||||
/>,
|
||||
);
|
||||
} else if (['image', 'gifv', 'unknown'].includes(status.getIn(['media_attachments', 0, 'type'])) || status.get('media_attachments').size > 1) {
|
||||
media.push(
|
||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
||||
{Component => (
|
||||
<Component
|
||||
media={attachments}
|
||||
lang={language}
|
||||
sensitive={status.get('sensitive')}
|
||||
letterbox={settings.getIn(['media', 'letterbox'])}
|
||||
fullwidth={!rootId && settings.getIn(['media', 'fullwidth'])}
|
||||
hidden={isCollapsed || !isExpanded}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
/>
|
||||
)}
|
||||
</Bundle>,
|
||||
);
|
||||
mediaIcons.push('picture-o');
|
||||
} else if (attachments.getIn([0, 'type']) === 'audio') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
||||
@@ -703,27 +727,6 @@ class Status extends ImmutablePureComponent {
|
||||
</Bundle>,
|
||||
);
|
||||
mediaIcons.push('video-camera');
|
||||
} else { // Media type is 'image' or 'gifv'
|
||||
media.push(
|
||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
||||
{Component => (
|
||||
<Component
|
||||
media={attachments}
|
||||
lang={language}
|
||||
sensitive={status.get('sensitive')}
|
||||
letterbox={settings.getIn(['media', 'letterbox'])}
|
||||
fullwidth={!rootId && settings.getIn(['media', 'fullwidth'])}
|
||||
hidden={isCollapsed || !isExpanded}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
/>
|
||||
)}
|
||||
</Bundle>,
|
||||
);
|
||||
mediaIcons.push('picture-o');
|
||||
}
|
||||
|
||||
if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
|
||||
@@ -806,7 +809,8 @@ class Status extends ImmutablePureComponent {
|
||||
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
|
||||
|
||||
{(!muted || !isCollapsed) && (
|
||||
<header className='status__info'>
|
||||
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
|
||||
<header onClick={this.parseClick} className='status__info'>
|
||||
<StatusHeader
|
||||
status={status}
|
||||
friend={account}
|
||||
|
||||
@@ -233,7 +233,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
menu.push({ text: intl.formatMessage(messages.share), action: this.handleShareClick });
|
||||
}
|
||||
|
||||
if (publicStatus && (signedIn || !isRemote)) {
|
||||
if (publicStatus && !isRemote) {
|
||||
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
|
||||
}
|
||||
|
||||
@@ -315,36 +315,48 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
const filterButton = this.props.onFilter && (
|
||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' iconComponent={VisibilityIcon} onClick={this.handleHideClick} />
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' iconComponent={VisibilityIcon} onClick={this.handleHideClick} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<IconButton
|
||||
className='status__action-bar-button'
|
||||
title={replyTitle}
|
||||
icon={replyIcon}
|
||||
iconComponent={replyIconComponent}
|
||||
onClick={this.handleReplyClick}
|
||||
counter={showReplyCount ? status.get('replies_count') : undefined}
|
||||
obfuscateCount
|
||||
/>
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
<IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton
|
||||
className='status__action-bar-button'
|
||||
title={replyTitle}
|
||||
icon={replyIcon}
|
||||
iconComponent={replyIconComponent}
|
||||
onClick={this.handleReplyClick}
|
||||
counter={showReplyCount ? status.get('replies_count') : undefined}
|
||||
obfuscateCount
|
||||
/>
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
|
||||
</div>
|
||||
|
||||
{filterButton}
|
||||
|
||||
<DropdownMenuContainer
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
size={18}
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
ariaLabel={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<DropdownMenuContainer
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
size={18}
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
ariaLabel={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='status__action-bar-spacer' />
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
|
||||
|
||||
42
app/javascript/flavours/glitch/components/status_banner.tsx
Normal file
42
app/javascript/flavours/glitch/components/status_banner.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export enum BannerVariant {
|
||||
Warning = 'warning',
|
||||
Filter = 'filter',
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
<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>
|
||||
);
|
||||
@@ -14,6 +14,7 @@ import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import LinkIcon from '@/material-icons/400-24px/link.svg?react';
|
||||
import MovieIcon from '@/material-icons/400-24px/movie.svg?react';
|
||||
import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react';
|
||||
import { ContentWarning } from 'flavours/glitch/components/content_warning';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
import { autoPlayGif, languages as preloadedLanguages } from 'flavours/glitch/initial_state';
|
||||
@@ -350,7 +351,7 @@ class StatusContent extends PureComponent {
|
||||
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
|
||||
|
||||
const content = { __html: statusContent ?? getStatusContent(status) };
|
||||
const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') };
|
||||
const spoilerHtml = status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml');
|
||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||
const classNames = classnames('status__content', {
|
||||
'status__content--with-action': parseClick && !disabled,
|
||||
@@ -375,45 +376,26 @@ class StatusContent extends PureComponent {
|
||||
</Permalink>
|
||||
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
|
||||
|
||||
let toggleText = null;
|
||||
if (hidden) {
|
||||
toggleText = [
|
||||
<FormattedMessage
|
||||
id='status.show_more'
|
||||
defaultMessage='Show more'
|
||||
key='0'
|
||||
/>,
|
||||
];
|
||||
if (mediaIcons) {
|
||||
const mediaComponents = {
|
||||
'link': LinkIcon,
|
||||
'picture-o': ImageIcon,
|
||||
'tasks': InsertChartIcon,
|
||||
'video-camera': MovieIcon,
|
||||
'music': MusicNoteIcon,
|
||||
};
|
||||
let spoilerIcons = [];
|
||||
if (hidden && mediaIcons) {
|
||||
const mediaComponents = {
|
||||
'link': LinkIcon,
|
||||
'picture-o': ImageIcon,
|
||||
'tasks': InsertChartIcon,
|
||||
'video-camera': MovieIcon,
|
||||
'music': MusicNoteIcon,
|
||||
};
|
||||
|
||||
mediaIcons.forEach((mediaIcon, idx) => {
|
||||
toggleText.push(
|
||||
<Icon
|
||||
fixedWidth
|
||||
className='status__content__spoiler-icon'
|
||||
id={mediaIcon}
|
||||
icon={mediaComponents[mediaIcon]}
|
||||
aria-hidden='true'
|
||||
key={`icon-${idx}`}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toggleText = (
|
||||
<FormattedMessage
|
||||
id='status.show_less'
|
||||
defaultMessage='Show less'
|
||||
key='0'
|
||||
spoilerIcons = mediaIcons.map((mediaIcon) => (
|
||||
<Icon
|
||||
fixedWidth
|
||||
className='status__content__spoiler-icon'
|
||||
id={mediaIcon}
|
||||
icon={mediaComponents[mediaIcon]}
|
||||
aria-hidden='true'
|
||||
key={`icon-${mediaIcon}`}
|
||||
/>
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
@@ -422,15 +404,7 @@ class StatusContent extends PureComponent {
|
||||
|
||||
return (
|
||||
<div className={classNames} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||
<p
|
||||
style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={language} />
|
||||
{' '}
|
||||
<button type='button' className='status__content__spoiler-link' onClick={this.handleSpoilerClick} aria-expanded={!hidden}>
|
||||
{toggleText}
|
||||
</button>
|
||||
</p>
|
||||
<ContentWarning text={spoilerHtml} expanded={!hidden} onClick={this.handleSpoilerClick} icons={spoilerIcons} />
|
||||
|
||||
{mentionsPlaceholder}
|
||||
|
||||
|
||||
@@ -18,15 +18,10 @@ export default class StatusHeader extends PureComponent {
|
||||
parseClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// Handles clicks on account name/image
|
||||
handleClick = (acct, e) => {
|
||||
const { parseClick } = this.props;
|
||||
parseClick(e, `/@${acct}`);
|
||||
};
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
const { status } = this.props;
|
||||
this.handleClick(status.getIn(['account', 'acct']), e);
|
||||
const { status, parseClick } = this.props;
|
||||
parseClick(e, `/@${status.getIn(['account', 'acct'])}`);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Rendering.
|
||||
|
||||
@@ -43,10 +43,7 @@ class AccountNavigation extends PureComponent {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex-spacer' />
|
||||
<FeaturedTags accountId={accountId} tagged={tagged} />
|
||||
</>
|
||||
<FeaturedTags accountId={accountId} tagged={tagged} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import AudiotrackIcon from '@/material-icons/400-24px/music_note.svg?react';
|
||||
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow.svg?react';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
|
||||
|
||||
export default class MediaItem extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
attachment: ImmutablePropTypes.map.isRequired,
|
||||
displayWidth: PropTypes.number.isRequired,
|
||||
onOpenMedia: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
handleImageLoad = () => {
|
||||
this.setState({ loaded: true });
|
||||
};
|
||||
|
||||
handleMouseEnter = e => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.play();
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseLeave = e => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.pause();
|
||||
e.target.currentTime = 0;
|
||||
}
|
||||
};
|
||||
|
||||
hoverToPlay () {
|
||||
return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.state.visible) {
|
||||
this.props.onOpenMedia(this.props.attachment);
|
||||
} else {
|
||||
this.setState({ visible: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { attachment, displayWidth } = this.props;
|
||||
const { visible, loaded } = this.state;
|
||||
|
||||
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
|
||||
const height = width;
|
||||
const status = attachment.get('status');
|
||||
const title = status.get('spoiler_text') || attachment.get('description');
|
||||
|
||||
let thumbnail, label, icon, content;
|
||||
|
||||
if (!visible) {
|
||||
icon = (
|
||||
<span className='account-gallery__item__icons'>
|
||||
<Icon id='eye-slash' icon={VisibilityOffIcon} />
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
if (['audio', 'video'].includes(attachment.get('type'))) {
|
||||
content = (
|
||||
<img
|
||||
src={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||
alt={attachment.get('description')}
|
||||
lang={status.get('language')}
|
||||
onLoad={this.handleImageLoad}
|
||||
/>
|
||||
);
|
||||
|
||||
if (attachment.get('type') === 'audio') {
|
||||
label = <Icon id='music' icon={AudiotrackIcon} />;
|
||||
} else {
|
||||
label = <Icon id='play' icon={PlayArrowIcon} />;
|
||||
}
|
||||
} else if (attachment.get('type') === 'image') {
|
||||
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
|
||||
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
|
||||
const x = ((focusX / 2) + .5) * 100;
|
||||
const y = ((focusY / -2) + .5) * 100;
|
||||
|
||||
content = (
|
||||
<img
|
||||
src={attachment.get('preview_url')}
|
||||
alt={attachment.get('description')}
|
||||
lang={status.get('language')}
|
||||
style={{ objectPosition: `${x}% ${y}%` }}
|
||||
onLoad={this.handleImageLoad}
|
||||
/>
|
||||
);
|
||||
} else if (attachment.get('type') === 'gifv') {
|
||||
content = (
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
aria-label={attachment.get('description')}
|
||||
title={attachment.get('description')}
|
||||
lang={status.get('language')}
|
||||
role='application'
|
||||
src={attachment.get('url')}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
autoPlay={autoPlayGif}
|
||||
playsInline
|
||||
loop
|
||||
muted
|
||||
/>
|
||||
);
|
||||
|
||||
label = 'GIF';
|
||||
}
|
||||
|
||||
thumbnail = (
|
||||
<div className='media-gallery__gifv'>
|
||||
{content}
|
||||
|
||||
{label && (
|
||||
<div className='media-gallery__item__badges'>
|
||||
<span className='media-gallery__gifv__label'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account-gallery__item' style={{ width, height }}>
|
||||
<a className='media-gallery__item-thumbnail' href={status.get('url')} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'>
|
||||
<Blurhash
|
||||
hash={attachment.get('blurhash')}
|
||||
className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })}
|
||||
dummy={!useBlurhash}
|
||||
/>
|
||||
|
||||
{visible ? thumbnail : icon}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import HeadphonesIcon from '@/material-icons/400-24px/headphones-fill.svg?react';
|
||||
import MovieIcon from '@/material-icons/400-24px/movie-fill.svg?react';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import { AltTextBadge } from 'flavours/glitch/components/alt_text_badge';
|
||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { formatTime } from 'flavours/glitch/features/video';
|
||||
import {
|
||||
autoPlayGif,
|
||||
displayMedia,
|
||||
useBlurhash,
|
||||
} from 'flavours/glitch/initial_state';
|
||||
import type { Status, MediaAttachment } from 'flavours/glitch/models/status';
|
||||
|
||||
export const MediaItem: React.FC<{
|
||||
attachment: MediaAttachment;
|
||||
onOpenMedia: (arg0: MediaAttachment) => void;
|
||||
}> = ({ attachment, onOpenMedia }) => {
|
||||
const [visible, setVisible] = useState(
|
||||
(displayMedia !== 'hide_all' &&
|
||||
!attachment.getIn(['status', 'sensitive'])) ||
|
||||
displayMedia === 'show_all',
|
||||
);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const handleImageLoad = useCallback(() => {
|
||||
setLoaded(true);
|
||||
}, [setLoaded]);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
if (e.target instanceof HTMLVideoElement) {
|
||||
void e.target.play();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
if (e.target instanceof HTMLVideoElement) {
|
||||
e.target.pause();
|
||||
e.target.currentTime = 0;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (visible) {
|
||||
onOpenMedia(attachment);
|
||||
} else {
|
||||
setVisible(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[attachment, visible, onOpenMedia, setVisible],
|
||||
);
|
||||
|
||||
const status = attachment.get('status') as Status;
|
||||
const description = (attachment.getIn(['translation', 'description']) ||
|
||||
attachment.get('description')) as string | undefined;
|
||||
const previewUrl = attachment.get('preview_url') as string;
|
||||
const fullUrl = attachment.get('url') as string;
|
||||
const avatarUrl = status.getIn(['account', 'avatar_static']) as string;
|
||||
const lang = status.get('language') as string;
|
||||
const blurhash = attachment.get('blurhash') as string;
|
||||
const statusUrl = status.get('url') as string;
|
||||
const type = attachment.get('type') as string;
|
||||
|
||||
let thumbnail;
|
||||
|
||||
const badges = [];
|
||||
|
||||
if (description && description.length > 0) {
|
||||
badges.push(<AltTextBadge key='alt' description={description} />);
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
thumbnail = (
|
||||
<div className='media-gallery__item__overlay'>
|
||||
<Icon id='eye-slash' icon={VisibilityOffIcon} />
|
||||
</div>
|
||||
);
|
||||
} else if (type === 'audio') {
|
||||
thumbnail = (
|
||||
<>
|
||||
<img
|
||||
src={previewUrl || avatarUrl}
|
||||
alt={description}
|
||||
title={description}
|
||||
lang={lang}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
|
||||
<div className='media-gallery__item__overlay media-gallery__item__overlay--corner'>
|
||||
<Icon id='music' icon={HeadphonesIcon} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else if (type === 'image') {
|
||||
const focusX = (attachment.getIn(['meta', 'focus', 'x']) || 0) as number;
|
||||
const focusY = (attachment.getIn(['meta', 'focus', 'y']) || 0) as number;
|
||||
const x = (focusX / 2 + 0.5) * 100;
|
||||
const y = (focusY / -2 + 0.5) * 100;
|
||||
|
||||
thumbnail = (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={description}
|
||||
title={description}
|
||||
lang={lang}
|
||||
style={{ objectPosition: `${x}% ${y}%` }}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
);
|
||||
} else if (['video', 'gifv'].includes(type)) {
|
||||
const duration = attachment.getIn([
|
||||
'meta',
|
||||
'original',
|
||||
'duration',
|
||||
]) as number;
|
||||
|
||||
thumbnail = (
|
||||
<div className='media-gallery__gifv'>
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
aria-label={description}
|
||||
title={description}
|
||||
lang={lang}
|
||||
src={fullUrl}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onLoadedData={handleImageLoad}
|
||||
autoPlay={autoPlayGif}
|
||||
playsInline
|
||||
loop
|
||||
muted
|
||||
/>
|
||||
|
||||
{type === 'video' && (
|
||||
<div className='media-gallery__item__overlay media-gallery__item__overlay--corner'>
|
||||
<Icon id='play' icon={MovieIcon} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (type === 'gifv') {
|
||||
badges.push(
|
||||
<span
|
||||
key='gif'
|
||||
className='media-gallery__alt__label media-gallery__alt__label--non-interactive'
|
||||
>
|
||||
GIF
|
||||
</span>,
|
||||
);
|
||||
} else {
|
||||
badges.push(
|
||||
<span
|
||||
key='video'
|
||||
className='media-gallery__alt__label media-gallery__alt__label--non-interactive'
|
||||
>
|
||||
{formatTime(Math.floor(duration))}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='media-gallery__item media-gallery__item--square'>
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
className={classNames('media-gallery__preview', {
|
||||
'media-gallery__preview--hidden': visible && loaded,
|
||||
})}
|
||||
dummy={!useBlurhash}
|
||||
/>
|
||||
|
||||
<a
|
||||
className='media-gallery__item-thumbnail'
|
||||
href={statusUrl}
|
||||
onClick={handleClick}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{thumbnail}
|
||||
</a>
|
||||
|
||||
{badges.length > 0 && (
|
||||
<div className='media-gallery__item__badges'>{badges}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -20,7 +20,7 @@ import { expandAccountMediaTimeline } from '../../actions/timelines';
|
||||
import HeaderContainer from '../account_timeline/containers/header_container';
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
import MediaItem from './components/media_item';
|
||||
import { MediaItem } from './components/media_item';
|
||||
|
||||
const mapStateToProps = (state, { params: { acct, id } }) => {
|
||||
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
|
||||
|
||||
@@ -27,15 +27,19 @@ class ColumnSettings extends PureComponent {
|
||||
|
||||
return (
|
||||
<div className='column-settings'>
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
|
||||
</div>
|
||||
<section>
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
|
||||
<section>
|
||||
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
|
||||
</div>
|
||||
<div className='column-settings__row'>
|
||||
<SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ const messages = defineMessages({
|
||||
export const SensitiveButton = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const spoilersAlwaysOn = useAppSelector((state) => state.getIn(['local_settings', 'always_show_spoilers_field']));
|
||||
const spoilerText = useAppSelector((state) => state.getIn(['compose', 'spoiler_text']));
|
||||
const sensitive = useAppSelector((state) => state.getIn(['compose', 'sensitive']));
|
||||
const spoiler = useAppSelector((state) => state.getIn(['compose', 'spoiler']));
|
||||
const mediaCount = useAppSelector((state) => state.getIn(['compose', 'media_attachments']).size);
|
||||
const spoilersAlwaysOn = useAppSelector((state) => state.local_settings.getIn(['always_show_spoilers_field']));
|
||||
const spoilerText = useAppSelector((state) => state.compose.get('spoiler_text'));
|
||||
const sensitive = useAppSelector((state) => state.compose.get('sensitive'));
|
||||
const spoiler = useAppSelector((state) => state.compose.get('spoiler'));
|
||||
const mediaCount = useAppSelector((state) => state.compose.get('media_attachments').size);
|
||||
const disabled = spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler;
|
||||
|
||||
const active = sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0);
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
||||
import { undoUploadCompose, initMediaEditModal } from 'flavours/glitch/actions/compose';
|
||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import Motion from 'flavours/glitch/features/ui/util/optional_motion';
|
||||
|
||||
export const Upload = ({ id, onDragStart, onDragEnter, onDragEnd }) => {
|
||||
const dispatch = useDispatch();
|
||||
const media = useSelector(state => state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id));
|
||||
const sensitive = useSelector(state => state.getIn(['compose', 'sensitive']));
|
||||
|
||||
const handleUndoClick = useCallback(() => {
|
||||
dispatch(undoUploadCompose(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleFocalPointClick = useCallback(() => {
|
||||
dispatch(initMediaEditModal(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
onDragStart(id);
|
||||
}, [onDragStart, id]);
|
||||
|
||||
const handleDragEnter = useCallback(() => {
|
||||
onDragEnter(id);
|
||||
}, [onDragEnter, id]);
|
||||
|
||||
if (!media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const focusX = media.getIn(['meta', 'focus', 'x']);
|
||||
const focusY = media.getIn(['meta', 'focus', 'y']);
|
||||
const x = ((focusX / 2) + .5) * 100;
|
||||
const y = ((focusY / -2) + .5) * 100;
|
||||
const missingDescription = (media.get('description') || '').length === 0;
|
||||
|
||||
return (
|
||||
<div className='compose-form__upload' draggable onDragStart={handleDragStart} onDragEnter={handleDragEnter} onDragEnd={onDragEnd}>
|
||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
||||
{({ scale }) => (
|
||||
<div className='compose-form__upload__thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: !sensitive ? `url(${media.get('preview_url')})` : null, backgroundPosition: `${x}% ${y}%` }}>
|
||||
{sensitive && <Blurhash
|
||||
hash={media.get('blurhash')}
|
||||
className='compose-form__upload__preview'
|
||||
/>}
|
||||
|
||||
<div className='compose-form__upload__actions'>
|
||||
<button type='button' className='icon-button compose-form__upload__delete' onClick={handleUndoClick}><Icon icon={CloseIcon} /></button>
|
||||
<button type='button' className='icon-button' onClick={handleFocalPointClick}><Icon icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
|
||||
</div>
|
||||
|
||||
<div className='compose-form__upload__warning'>
|
||||
<button type='button' className={classNames('icon-button', { active: missingDescription })} onClick={handleFocalPointClick}>{missingDescription && <Icon icon={WarningIcon} />} ALT</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Upload.propTypes = {
|
||||
id: PropTypes.string,
|
||||
onDragEnter: PropTypes.func,
|
||||
onDragStart: PropTypes.func,
|
||||
onDragEnd: PropTypes.func,
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
||||
import {
|
||||
undoUploadCompose,
|
||||
initMediaEditModal,
|
||||
} from 'flavours/glitch/actions/compose';
|
||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
export const Upload: React.FC<{
|
||||
id: string;
|
||||
dragging?: boolean;
|
||||
overlay?: boolean;
|
||||
tall?: boolean;
|
||||
wide?: boolean;
|
||||
}> = ({ id, dragging, overlay, tall, wide }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const media = useAppSelector(
|
||||
(state) =>
|
||||
state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
|
||||
.get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
.find((item: MediaAttachment) => item.get('id') === id) as // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
| MediaAttachment
|
||||
| undefined,
|
||||
);
|
||||
const sensitive = useAppSelector(
|
||||
(state) => state.compose.get('sensitive') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
|
||||
const handleUndoClick = useCallback(() => {
|
||||
dispatch(undoUploadCompose(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleFocalPointClick = useCallback(() => {
|
||||
dispatch(initMediaEditModal(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id });
|
||||
|
||||
if (!media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const focusX = media.getIn(['meta', 'focus', 'x']) as number;
|
||||
const focusY = media.getIn(['meta', 'focus', 'y']) as number;
|
||||
const x = (focusX / 2 + 0.5) * 100;
|
||||
const y = (focusY / -2 + 0.5) * 100;
|
||||
const missingDescription =
|
||||
((media.get('description') as string | undefined) ?? '').length === 0;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('compose-form__upload media-gallery__item', {
|
||||
dragging,
|
||||
overlay,
|
||||
'media-gallery__item--tall': tall,
|
||||
'media-gallery__item--wide': wide,
|
||||
})}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div
|
||||
className='compose-form__upload__thumbnail'
|
||||
style={{
|
||||
backgroundImage: !sensitive
|
||||
? `url(${media.get('preview_url') as string})`
|
||||
: undefined,
|
||||
backgroundPosition: `${x}% ${y}%`,
|
||||
}}
|
||||
>
|
||||
{sensitive && (
|
||||
<Blurhash
|
||||
hash={media.get('blurhash') as string}
|
||||
className='compose-form__upload__preview'
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='compose-form__upload__actions'>
|
||||
<button
|
||||
type='button'
|
||||
className='icon-button compose-form__upload__delete'
|
||||
onClick={handleUndoClick}
|
||||
>
|
||||
<Icon id='close' icon={CloseIcon} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='icon-button'
|
||||
onClick={handleFocalPointClick}
|
||||
>
|
||||
<Icon id='edit' icon={EditIcon} />{' '}
|
||||
<FormattedMessage id='upload_form.edit' defaultMessage='Edit' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='compose-form__upload__warning'>
|
||||
<button
|
||||
type='button'
|
||||
className={classNames('icon-button', {
|
||||
active: missingDescription,
|
||||
})}
|
||||
onClick={handleFocalPointClick}
|
||||
>
|
||||
{missingDescription && <Icon id='warning' icon={WarningIcon} />} ALT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import { changeMediaOrder } from 'flavours/glitch/actions/compose';
|
||||
|
||||
import { SensitiveButton } from './sensitive_button';
|
||||
import { Upload } from './upload';
|
||||
import { UploadProgress } from './upload_progress';
|
||||
|
||||
export const UploadForm = () => {
|
||||
const dispatch = useDispatch();
|
||||
const mediaIds = useSelector(state => state.getIn(['compose', 'media_attachments']).map(item => item.get('id')));
|
||||
const active = useSelector(state => state.getIn(['compose', 'is_uploading']));
|
||||
const progress = useSelector(state => state.getIn(['compose', 'progress']));
|
||||
const isProcessing = useSelector(state => state.getIn(['compose', 'is_processing']));
|
||||
|
||||
const dragItem = useRef();
|
||||
const dragOverItem = useRef();
|
||||
|
||||
const handleDragStart = useCallback(id => {
|
||||
dragItem.current = id;
|
||||
}, [dragItem]);
|
||||
|
||||
const handleDragEnter = useCallback(id => {
|
||||
dragOverItem.current = id;
|
||||
}, [dragOverItem]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
dispatch(changeMediaOrder(dragItem.current, dragOverItem.current));
|
||||
dragItem.current = null;
|
||||
dragOverItem.current = null;
|
||||
}, [dispatch, dragItem, dragOverItem]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UploadProgress active={active} progress={progress} isProcessing={isProcessing} />
|
||||
|
||||
{mediaIds.size > 0 && (
|
||||
<div className='compose-form__uploads'>
|
||||
{mediaIds.map(id => (
|
||||
<Upload
|
||||
key={id}
|
||||
id={id}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!mediaIds.isEmpty() && <SensitiveButton />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import type { List } from 'immutable';
|
||||
|
||||
import type {
|
||||
DragStartEvent,
|
||||
DragEndEvent,
|
||||
UniqueIdentifier,
|
||||
Announcements,
|
||||
ScreenReaderInstructions,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
|
||||
import { changeMediaOrder } from 'flavours/glitch/actions/compose';
|
||||
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
import { SensitiveButton } from './sensitive_button';
|
||||
import { Upload } from './upload';
|
||||
import { UploadProgress } from './upload_progress';
|
||||
|
||||
const messages = defineMessages({
|
||||
screenReaderInstructions: {
|
||||
id: 'upload_form.drag_and_drop.instructions',
|
||||
defaultMessage:
|
||||
'To pick up a media attachment, press space or enter. While dragging, use the arrow keys to move the media attachment in any given direction. Press space or enter again to drop the media attachment in its new position, or press escape to cancel.',
|
||||
},
|
||||
onDragStart: {
|
||||
id: 'upload_form.drag_and_drop.on_drag_start',
|
||||
defaultMessage: 'Picked up media attachment {item}.',
|
||||
},
|
||||
onDragOver: {
|
||||
id: 'upload_form.drag_and_drop.on_drag_over',
|
||||
defaultMessage: 'Media attachment {item} was moved.',
|
||||
},
|
||||
onDragEnd: {
|
||||
id: 'upload_form.drag_and_drop.on_drag_end',
|
||||
defaultMessage: 'Media attachment {item} was dropped.',
|
||||
},
|
||||
onDragCancel: {
|
||||
id: 'upload_form.drag_and_drop.on_drag_cancel',
|
||||
defaultMessage:
|
||||
'Dragging was cancelled. Media attachment {item} was dropped.',
|
||||
},
|
||||
});
|
||||
|
||||
export const UploadForm: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const mediaIds = useAppSelector(
|
||||
(state) =>
|
||||
state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
|
||||
.get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
.map((item: MediaAttachment) => item.get('id')) as List<string>, // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
const active = useAppSelector(
|
||||
(state) => state.compose.get('is_uploading') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
const progress = useAppSelector(
|
||||
(state) => state.compose.get('progress') as number, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
const isProcessing = useAppSelector(
|
||||
(state) => state.compose.get('is_processing') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 5,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: DragStartEvent) => {
|
||||
const { active } = e;
|
||||
|
||||
setActiveId(active.id);
|
||||
},
|
||||
[setActiveId],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(e: DragEndEvent) => {
|
||||
const { active, over } = e;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
dispatch(changeMediaOrder(active.id, over.id));
|
||||
}
|
||||
|
||||
setActiveId(null);
|
||||
},
|
||||
[dispatch, setActiveId],
|
||||
);
|
||||
|
||||
const accessibility: {
|
||||
screenReaderInstructions: ScreenReaderInstructions;
|
||||
announcements: Announcements;
|
||||
} = useMemo(
|
||||
() => ({
|
||||
screenReaderInstructions: {
|
||||
draggable: intl.formatMessage(messages.screenReaderInstructions),
|
||||
},
|
||||
|
||||
announcements: {
|
||||
onDragStart({ active }) {
|
||||
return intl.formatMessage(messages.onDragStart, { item: active.id });
|
||||
},
|
||||
|
||||
onDragOver({ active }) {
|
||||
return intl.formatMessage(messages.onDragOver, { item: active.id });
|
||||
},
|
||||
|
||||
onDragEnd({ active }) {
|
||||
return intl.formatMessage(messages.onDragEnd, { item: active.id });
|
||||
},
|
||||
|
||||
onDragCancel({ active }) {
|
||||
return intl.formatMessage(messages.onDragCancel, { item: active.id });
|
||||
},
|
||||
},
|
||||
}),
|
||||
[intl],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UploadProgress
|
||||
active={active}
|
||||
progress={progress}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
|
||||
{mediaIds.size > 0 && (
|
||||
<div
|
||||
className={`compose-form__uploads media-gallery media-gallery--layout-${mediaIds.size}`}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
accessibility={accessibility}
|
||||
>
|
||||
<SortableContext
|
||||
items={mediaIds.toArray()}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
{mediaIds.map((id, idx) => (
|
||||
<Upload
|
||||
key={id}
|
||||
id={id}
|
||||
dragging={id === activeId}
|
||||
tall={mediaIds.size < 3 || (mediaIds.size === 3 && idx === 0)}
|
||||
wide={mediaIds.size === 1}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId ? <Upload id={activeId as string} overlay /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!mediaIds.isEmpty() && <SensitiveButton />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChangeEventHandler } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
@@ -26,6 +26,8 @@ import { RadioButton } from 'flavours/glitch/components/radio_button';
|
||||
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import { useSearchParam } from '../../hooks/useSearchParam';
|
||||
|
||||
import { AccountCard } from './components/account_card';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -50,18 +52,19 @@ export const Directory: React.FC<{
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [state, setState] = useState<{
|
||||
order: string | null;
|
||||
local: boolean | null;
|
||||
}>({
|
||||
order: null,
|
||||
local: null,
|
||||
});
|
||||
|
||||
const column = useRef<Column>(null);
|
||||
|
||||
const order = state.order ?? params?.order ?? 'active';
|
||||
const local = state.local ?? params?.local ?? false;
|
||||
const [orderParam, setOrderParam] = useSearchParam('order');
|
||||
const [localParam, setLocalParam] = useSearchParam('local');
|
||||
|
||||
let localParamBool: boolean | undefined;
|
||||
|
||||
if (localParam === 'false') {
|
||||
localParamBool = false;
|
||||
}
|
||||
|
||||
const order = orderParam ?? params?.order ?? 'active';
|
||||
const local = localParamBool ?? params?.local ?? true;
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
if (columnId) {
|
||||
@@ -104,10 +107,10 @@ export const Directory: React.FC<{
|
||||
if (columnId) {
|
||||
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
|
||||
} else {
|
||||
setState((s) => ({ order: e.target.value, local: s.local }));
|
||||
setOrderParam(e.target.value);
|
||||
}
|
||||
},
|
||||
[dispatch, columnId],
|
||||
[dispatch, columnId, setOrderParam],
|
||||
);
|
||||
|
||||
const handleChangeLocal = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||
@@ -116,11 +119,13 @@ export const Directory: React.FC<{
|
||||
dispatch(
|
||||
changeColumnParams(columnId, ['local'], e.target.value === '1'),
|
||||
);
|
||||
} else if (e.target.value === '1') {
|
||||
setLocalParam('true');
|
||||
} else {
|
||||
setState((s) => ({ local: e.target.value === '1', order: s.order }));
|
||||
setLocalParam('false');
|
||||
}
|
||||
},
|
||||
[dispatch, columnId],
|
||||
[dispatch, columnId, setLocalParam],
|
||||
);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
|
||||
@@ -129,8 +129,13 @@ export const InlineFollowSuggestions = ({ hidden }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
|
||||
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
|
||||
setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth);
|
||||
setCanScrollRight(bodyRef.current.scrollLeft < 0);
|
||||
} else {
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
|
||||
}
|
||||
}, [setCanScrollRight, setCanScrollLeft, bodyRef, suggestions]);
|
||||
|
||||
const handleLeftNav = useCallback(() => {
|
||||
@@ -146,8 +151,13 @@ export const InlineFollowSuggestions = ({ hidden }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
|
||||
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
|
||||
setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth);
|
||||
setCanScrollRight(bodyRef.current.scrollLeft < 0);
|
||||
} else {
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
|
||||
}
|
||||
}, [setCanScrollRight, setCanScrollLeft, bodyRef]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
|
||||
@@ -40,6 +40,7 @@ class ColumnSettings extends PureComponent {
|
||||
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
|
||||
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
|
||||
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
|
||||
const groupStr = <FormattedMessage id='notifications.column_settings.group' defaultMessage='Group' />;
|
||||
|
||||
const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
|
||||
const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
|
||||
@@ -96,6 +97,10 @@ class ColumnSettings extends PureComponent {
|
||||
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
|
||||
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
|
||||
</div>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['group', 'follow']} onChange={onChange} label={groupStr} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section role='group' aria-labelledby='notifications-follow-request'>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const FilteredNotificationsIconButton: React.FC<{
|
||||
history.push('/notifications/requests');
|
||||
}, [history]);
|
||||
|
||||
if (policy === null || policy.summary.pending_notifications_count === 0) {
|
||||
if (policy === null || policy.summary.pending_requests_count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export const FilteredNotificationsBanner: React.FC = () => {
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
if (policy === null || policy.summary.pending_notifications_count === 0) {
|
||||
if (policy === null || policy.summary.pending_requests_count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,11 +56,12 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
} else {
|
||||
dispatch(changeSetting(['notifications', ...path], checked));
|
||||
}
|
||||
} else if(path[0] === 'groupingBeta') {
|
||||
dispatch(changeSetting(['notifications', ...path], checked));
|
||||
dispatch(initializeNotifications());
|
||||
} else {
|
||||
dispatch(changeSetting(['notifications', ...path], checked));
|
||||
|
||||
if(path[0] === 'group' && path[1] === 'follow') {
|
||||
dispatch(initializeNotifications());
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ import type { List as ImmutableList, RecordOf } from 'immutable';
|
||||
|
||||
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
|
||||
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
|
||||
import { toggleStatusSpoilers } from 'flavours/glitch/actions/statuses';
|
||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||
import { ContentWarning } from 'flavours/glitch/components/content_warning';
|
||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import type { Status } from 'flavours/glitch/models/status';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
import { EmbeddedStatusContent } from './embedded_status_content';
|
||||
|
||||
@@ -23,6 +25,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
const clickCoordinatesRef = useRef<[number, number] | null>();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const status = useAppSelector(
|
||||
(state) => state.statuses.get(statusId) as Status | undefined,
|
||||
@@ -96,15 +99,21 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const handleContentWarningClick = useCallback(() => {
|
||||
dispatch(toggleStatusSpoilers(statusId));
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Assign status attributes to variables with a forced type, as status is not yet properly typed
|
||||
const contentHtml = status.get('contentHtml') as string;
|
||||
const contentWarning = status.get('spoilerHtml') as string;
|
||||
const poll = status.get('poll');
|
||||
const language = status.get('language') as string;
|
||||
const mentions = status.get('mentions') as ImmutableList<Mention>;
|
||||
const expanded = !status.get('hidden') || !contentWarning;
|
||||
const mediaAttachmentsSize = (
|
||||
status.get('media_attachments') as ImmutableList<unknown>
|
||||
).size;
|
||||
@@ -124,14 +133,24 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
|
||||
<EmbeddedStatusContent
|
||||
className='notification-group__embedded-status__content reply-indicator__content translate'
|
||||
content={contentHtml}
|
||||
language={language}
|
||||
mentions={mentions}
|
||||
/>
|
||||
{contentWarning && (
|
||||
<ContentWarning
|
||||
text={contentWarning}
|
||||
onClick={handleContentWarningClick}
|
||||
expanded={expanded}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(poll || mediaAttachmentsSize > 0) && (
|
||||
{(!contentWarning || expanded) && (
|
||||
<EmbeddedStatusContent
|
||||
className='notification-group__embedded-status__content reply-indicator__content translate'
|
||||
content={contentHtml}
|
||||
language={language}
|
||||
mentions={mentions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{expanded && (poll || mediaAttachmentsSize > 0) && (
|
||||
<div className='notification-group__embedded-status__attachments reply-indicator__attachments'>
|
||||
{!!poll && (
|
||||
<>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
|
||||
import { FollowersCounter } from 'flavours/glitch/components/counters';
|
||||
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import type { NotificationGroupFollow } from 'flavours/glitch/models/notification_group';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
import { NotificationGroupWithStatus } from './notification_group_with_status';
|
||||
|
||||
const labelRenderer: LabelRenderer = (displayedName, total) => {
|
||||
const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => {
|
||||
if (total === 1)
|
||||
return (
|
||||
<FormattedMessage
|
||||
@@ -23,10 +26,12 @@ const labelRenderer: LabelRenderer = (displayedName, total) => {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='notification.follow.name_and_others'
|
||||
defaultMessage='{name} and {count, plural, one {# other} other {# others}} followed you'
|
||||
defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a> followed you'
|
||||
values={{
|
||||
name: displayedName,
|
||||
count: total - 1,
|
||||
a: (chunks) =>
|
||||
seeMoreHref ? <Link to={seeMoreHref}>{chunks}</Link> : chunks,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -46,6 +51,10 @@ export const NotificationFollow: React.FC<{
|
||||
notification: NotificationGroupFollow;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => {
|
||||
const username = useAppSelector(
|
||||
(state) => state.accounts.getIn([me, 'username']) as string,
|
||||
);
|
||||
|
||||
let actions: JSX.Element | undefined;
|
||||
let additionalContent: JSX.Element | undefined;
|
||||
|
||||
@@ -68,6 +77,7 @@ export const NotificationFollow: React.FC<{
|
||||
timestamp={notification.latest_page_notification_at}
|
||||
count={notification.notifications_count}
|
||||
labelRenderer={labelRenderer}
|
||||
labelSeeMoreHref={`/@${username}/followers`}
|
||||
unread={unread}
|
||||
actions={actions}
|
||||
additionalContent={additionalContent}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { IconProp } from 'flavours/glitch/components/icon';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import Status from 'flavours/glitch/containers/status_container';
|
||||
import { getStatusHidden } from 'flavours/glitch/selectors/filters';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
import { DisplayedName } from './displayed_name';
|
||||
@@ -51,6 +52,12 @@ export const NotificationWithStatus: React.FC<{
|
||||
(state) => state.statuses.getIn([statusId, 'visibility']) === 'direct',
|
||||
);
|
||||
|
||||
const isFiltered = useAppSelector(
|
||||
(state) =>
|
||||
statusId &&
|
||||
getStatusHidden(state, { id: statusId, contextType: 'notifications' }),
|
||||
);
|
||||
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
open: () => {
|
||||
@@ -77,7 +84,7 @@ export const NotificationWithStatus: React.FC<{
|
||||
[dispatch, statusId],
|
||||
);
|
||||
|
||||
if (!statusId) return null;
|
||||
if (!statusId || isFiltered) return null;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Icon } from 'flavours/glitch/components/icon';
|
||||
import {
|
||||
selectSettingsNotificationsQuickFilterActive,
|
||||
selectSettingsNotificationsQuickFilterAdvanced,
|
||||
selectSettingsNotificationsQuickFilterShow,
|
||||
} from 'flavours/glitch/selectors/settings';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
@@ -65,6 +66,11 @@ export const FilterBar: React.FC = () => {
|
||||
const advancedMode = useAppSelector(
|
||||
selectSettingsNotificationsQuickFilterAdvanced,
|
||||
);
|
||||
const useFilterBar = useAppSelector(
|
||||
selectSettingsNotificationsQuickFilterShow,
|
||||
);
|
||||
|
||||
if (!useFilterBar) return null;
|
||||
|
||||
if (advancedMode)
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,8 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
|
||||
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
|
||||
import { replyCompose } from 'flavours/glitch/actions/compose';
|
||||
import { toggleReblog, toggleFavourite } from 'flavours/glitch/actions/interactions';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
@@ -161,16 +163,20 @@ class Footer extends ImmutablePureComponent {
|
||||
replyTitle = intl.formatMessage(messages.replyAll);
|
||||
}
|
||||
|
||||
let reblogTitle = '';
|
||||
let reblogTitle, reblogIconComponent;
|
||||
|
||||
if (status.get('reblogged')) {
|
||||
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
|
||||
reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon;
|
||||
} else if (publicStatus) {
|
||||
reblogTitle = intl.formatMessage(messages.reblog);
|
||||
reblogIconComponent = RepeatIcon;
|
||||
} else if (reblogPrivate) {
|
||||
reblogTitle = intl.formatMessage(messages.reblog_private);
|
||||
reblogIconComponent = RepeatPrivateIcon;
|
||||
} else {
|
||||
reblogTitle = intl.formatMessage(messages.cannot_reblog);
|
||||
reblogIconComponent = RepeatDisabledIcon;
|
||||
}
|
||||
|
||||
let replyButton = null;
|
||||
@@ -201,7 +207,7 @@ class Footer extends ImmutablePureComponent {
|
||||
return (
|
||||
<div className='picture-in-picture__footer'>
|
||||
{replyButton}
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={RepeatIcon} onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={StarIcon} onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
|
||||
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' iconComponent={OpenInNewIcon} onClick={this.handleOpenClick} href={status.get('url')} />}
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Permalink } from 'flavours/glitch/components/permalink';
|
||||
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
||||
import { useAppHistory } from 'flavours/glitch/components/router';
|
||||
import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon';
|
||||
import PollContainer from 'flavours/glitch/containers/poll_container';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
@@ -194,6 +195,28 @@ export const DetailedStatus: React.FC<{
|
||||
)
|
||||
) {
|
||||
media.push(<AttachmentList media={status.get('media_attachments')} />);
|
||||
} else if (
|
||||
['image', 'gifv', 'unknown'].includes(
|
||||
status.getIn(['media_attachments', 0, 'type']) as string,
|
||||
) ||
|
||||
status.get('media_attachments').size > 1
|
||||
) {
|
||||
media.push(
|
||||
<MediaGallery
|
||||
standalone
|
||||
sensitive={status.get('sensitive')}
|
||||
media={status.get('media_attachments')}
|
||||
lang={language}
|
||||
height={300}
|
||||
letterbox={letterboxMedia}
|
||||
fullwidth={fullwidthMedia}
|
||||
hidden={!expanded}
|
||||
onOpenMedia={onOpenMedia}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
/>,
|
||||
);
|
||||
mediaIcons.push('picture-o');
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
const description =
|
||||
@@ -236,6 +259,7 @@ export const DetailedStatus: React.FC<{
|
||||
src={attachment.get('url')}
|
||||
alt={description}
|
||||
lang={language}
|
||||
inline
|
||||
width={300}
|
||||
height={150}
|
||||
onOpenVideo={handleOpenVideo}
|
||||
@@ -248,23 +272,6 @@ export const DetailedStatus: React.FC<{
|
||||
/>,
|
||||
);
|
||||
mediaIcons.push('video-camera');
|
||||
} else {
|
||||
media.push(
|
||||
<MediaGallery
|
||||
standalone
|
||||
sensitive={status.get('sensitive')}
|
||||
media={status.get('media_attachments')}
|
||||
lang={language}
|
||||
height={300}
|
||||
letterbox={letterboxMedia}
|
||||
fullwidth={fullwidthMedia}
|
||||
hidden={!expanded}
|
||||
onOpenMedia={onOpenMedia}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
/>,
|
||||
);
|
||||
mediaIcons.push('picture-o');
|
||||
}
|
||||
} else if (status.get('spoiler_text').length === 0) {
|
||||
media.push(
|
||||
@@ -277,6 +284,17 @@ export const DetailedStatus: React.FC<{
|
||||
mediaIcons.push('link');
|
||||
}
|
||||
|
||||
if (status.get('poll')) {
|
||||
contentMedia.push(
|
||||
<PollContainer
|
||||
pollId={status.get('poll')}
|
||||
// @ts-expect-error -- Poll/PollContainer is not typed yet
|
||||
lang={status.get('language')}
|
||||
/>,
|
||||
);
|
||||
contentMediaIcons.push('tasks');
|
||||
}
|
||||
|
||||
if (status.get('application')) {
|
||||
applicationLink = (
|
||||
<>
|
||||
|
||||
@@ -99,7 +99,7 @@ export const BlockModal = ({ accountId, acct }) => {
|
||||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
|
||||
<Button onClick={handleClick} autoFocus>
|
||||
<Button onClick={handleClick} dangerous autoFocus>
|
||||
<FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,11 @@ import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useRouteMatch, NavLink } from 'react-router-dom';
|
||||
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
|
||||
const ColumnLink = ({ icon, activeIcon, iconComponent, activeIconComponent, text, to, onClick, href, method, badge, transparent, ...other }) => {
|
||||
const ColumnLink = ({ icon, activeIcon, iconComponent, activeIconComponent, text, to, onClick, href, method, badge, transparent, optional, ...other }) => {
|
||||
const match = useRouteMatch(to);
|
||||
const className = classNames('column-link', { 'column-link--transparent': transparent });
|
||||
const className = classNames('column-link', { 'column-link--transparent': transparent, 'column-link--optional': optional });
|
||||
const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null;
|
||||
const iconElement = (typeof icon === 'string' || iconComponent) ? <Icon id={icon} icon={iconComponent} className='column-link__icon' /> : icon;
|
||||
const activeIconElement = activeIcon ?? (activeIconComponent ? <Icon id={icon} icon={activeIconComponent} className='column-link__icon' /> : iconElement);
|
||||
@@ -58,6 +58,7 @@ ColumnLink.propTypes = {
|
||||
method: PropTypes.string,
|
||||
badge: PropTypes.node,
|
||||
transparent: PropTypes.bool,
|
||||
optional: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ColumnLink;
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Children, cloneElement, useCallback } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
|
||||
import { scrollRight } from '../../../scroll';
|
||||
import BundleContainer from '../containers/bundle_container';
|
||||
import {
|
||||
@@ -65,17 +63,13 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
// Corresponds to (max-width: $no-gap-breakpoint - 1px) in SCSS
|
||||
mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1206px)');
|
||||
mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1174px)');
|
||||
|
||||
state = {
|
||||
renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.singleColumn) {
|
||||
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
|
||||
}
|
||||
|
||||
if (this.mediaQuery) {
|
||||
if (this.mediaQuery.addEventListener) {
|
||||
this.mediaQuery.addEventListener('change', this.handleLayoutChange);
|
||||
@@ -88,23 +82,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||
this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
|
||||
}
|
||||
|
||||
UNSAFE_componentWillUpdate(nextProps) {
|
||||
if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
|
||||
this.node.removeEventListener('wheel', this.handleWheel);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
|
||||
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (!this.props.singleColumn) {
|
||||
this.node.removeEventListener('wheel', this.handleWheel);
|
||||
}
|
||||
|
||||
if (this.mediaQuery) {
|
||||
if (this.mediaQuery.removeEventListener) {
|
||||
this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
|
||||
@@ -117,7 +95,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||
handleChildrenContentChange() {
|
||||
if (!this.props.singleColumn) {
|
||||
const modifier = this.isRtlLayout ? -1 : 1;
|
||||
this._interruptScrollAnimation = scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier);
|
||||
scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,14 +103,6 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||
this.setState({ renderComposePanel: !e.matches });
|
||||
};
|
||||
|
||||
handleWheel = () => {
|
||||
if (typeof this._interruptScrollAnimation !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
this._interruptScrollAnimation();
|
||||
};
|
||||
|
||||
setRef = (node) => {
|
||||
this.node = node;
|
||||
};
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import CampaignIcon from '@/material-icons/400-24px/campaign.svg?react';
|
||||
import DomainDisabledIcon from '@/material-icons/400-24px/domain_disabled.svg?react';
|
||||
import HistoryIcon from '@/material-icons/400-24px/history.svg?react';
|
||||
import PersonRemoveIcon from '@/material-icons/400-24px/person_remove.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import { blockAccount } from 'flavours/glitch/actions/accounts';
|
||||
import { blockDomain } from 'flavours/glitch/actions/domain_blocks';
|
||||
import { closeModal } from 'flavours/glitch/actions/modal';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
|
||||
export const DomainBlockModal = ({ domain, accountId, acct }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
dispatch(blockDomain(domain));
|
||||
}, [dispatch, domain]);
|
||||
|
||||
const handleSecondaryClick = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
dispatch(blockAccount(accountId));
|
||||
}, [dispatch, accountId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal safety-action-modal'>
|
||||
<div className='safety-action-modal__top'>
|
||||
<div className='safety-action-modal__header'>
|
||||
<div className='safety-action-modal__header__icon'>
|
||||
<Icon icon={DomainDisabledIcon} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1><FormattedMessage id='domain_block_modal.title' defaultMessage='Block domain?' /></h1>
|
||||
<div>{domain}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bullet-points'>
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={CampaignIcon} /></div>
|
||||
<div><FormattedMessage id='domain_block_modal.they_wont_know' defaultMessage="They won't know they've been blocked." /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={VisibilityOffIcon} /></div>
|
||||
<div><FormattedMessage id='domain_block_modal.you_wont_see_posts' defaultMessage="You won't see posts or notifications from users on this server." /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={PersonRemoveIcon} /></div>
|
||||
<div><FormattedMessage id='domain_block_modal.you_will_lose_followers' defaultMessage='All your followers from this server will be removed.' /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={ReplyIcon} /></div>
|
||||
<div><FormattedMessage id='domain_block_modal.they_cant_follow' defaultMessage='Nobody from this server can follow you.' /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={HistoryIcon} /></div>
|
||||
<div><FormattedMessage id='domain_block_modal.they_can_interact_with_old_posts' defaultMessage='People from this server can interact with your old posts.' /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bottom'>
|
||||
<div className='safety-action-modal__actions'>
|
||||
<Button onClick={handleSecondaryClick} secondary>
|
||||
<FormattedMessage id='domain_block_modal.block_account_instead' defaultMessage='Block @{name} instead' values={{ name: acct.split('@')[0] }} />
|
||||
</Button>
|
||||
|
||||
<div className='spacer' />
|
||||
|
||||
<button onClick={handleCancel} className='link-button'>
|
||||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
|
||||
<Button onClick={handleClick} autoFocus>
|
||||
<FormattedMessage id='domain_block_modal.block' defaultMessage='Block server' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DomainBlockModal.propTypes = {
|
||||
domain: PropTypes.string.isRequired,
|
||||
accountId: PropTypes.string.isRequired,
|
||||
acct: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default DomainBlockModal;
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import CampaignIcon from '@/material-icons/400-24px/campaign.svg?react';
|
||||
import DomainDisabledIcon from '@/material-icons/400-24px/domain_disabled.svg?react';
|
||||
import HistoryIcon from '@/material-icons/400-24px/history.svg?react';
|
||||
import PersonRemoveIcon from '@/material-icons/400-24px/person_remove.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import { blockAccount } from 'flavours/glitch/actions/accounts';
|
||||
import { blockDomain } from 'flavours/glitch/actions/domain_blocks';
|
||||
import { closeModal } from 'flavours/glitch/actions/modal';
|
||||
import { apiRequest } from 'flavours/glitch/api';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import { useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
interface DomainBlockPreviewResponse {
|
||||
following_count: number;
|
||||
followers_count: number;
|
||||
}
|
||||
|
||||
export const DomainBlockModal: React.FC<{
|
||||
domain: string;
|
||||
accountId: string;
|
||||
acct: string;
|
||||
}> = ({ domain, accountId, acct }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [preview, setPreview] = useState<
|
||||
DomainBlockPreviewResponse | 'error' | null
|
||||
>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (loading) {
|
||||
return; // Prevent destructive action before the preview finishes loading or times out
|
||||
}
|
||||
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
dispatch(blockDomain(domain));
|
||||
}, [dispatch, loading, domain]);
|
||||
|
||||
const handleSecondaryClick = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
dispatch(blockAccount(accountId));
|
||||
}, [dispatch, accountId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
apiRequest<DomainBlockPreviewResponse>('GET', 'v1/domain_blocks/preview', {
|
||||
params: { domain },
|
||||
timeout: 5000,
|
||||
})
|
||||
.then((data) => {
|
||||
setPreview(data);
|
||||
setLoading(false);
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setPreview('error');
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setPreview, setLoading, domain]);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal safety-action-modal' aria-live='polite'>
|
||||
<div className='safety-action-modal__top'>
|
||||
<div className='safety-action-modal__header'>
|
||||
<div className='safety-action-modal__header__icon'>
|
||||
<Icon id='' icon={DomainDisabledIcon} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.title'
|
||||
defaultMessage='Block domain?'
|
||||
/>
|
||||
</h1>
|
||||
<div>{domain}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bullet-points'>
|
||||
{preview &&
|
||||
preview !== 'error' &&
|
||||
preview.followers_count + preview.following_count > 0 && (
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'>
|
||||
<Icon id='' icon={PersonRemoveIcon} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.you_will_lose_num_followers'
|
||||
defaultMessage='You will lose {followersCount, plural, one {{followersCountDisplay} follower} other {{followersCountDisplay} followers}} and {followingCount, plural, one {{followingCountDisplay} person you follow} other {{followingCountDisplay} people you follow}}.'
|
||||
values={{
|
||||
followersCount: preview.followers_count,
|
||||
followersCountDisplay: (
|
||||
<ShortNumber value={preview.followers_count} />
|
||||
),
|
||||
followingCount: preview.following_count,
|
||||
followingCountDisplay: (
|
||||
<ShortNumber value={preview.following_count} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview === 'error' && (
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'>
|
||||
<Icon id='' icon={PersonRemoveIcon} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.you_will_lose_relationships'
|
||||
defaultMessage='You will lose all followers and people you follow from this server.'
|
||||
/>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='safety-action-modal__bullet-points--deemphasized'>
|
||||
<div className='safety-action-modal__bullet-points__icon'>
|
||||
<Icon id='' icon={CampaignIcon} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.they_wont_know'
|
||||
defaultMessage="They won't know they've been blocked."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bullet-points--deemphasized'>
|
||||
<div className='safety-action-modal__bullet-points__icon'>
|
||||
<Icon id='' icon={VisibilityOffIcon} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.you_wont_see_posts'
|
||||
defaultMessage="You won't see posts or notifications from users on this server."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bullet-points--deemphasized'>
|
||||
<div className='safety-action-modal__bullet-points__icon'>
|
||||
<Icon id='' icon={ReplyIcon} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.they_cant_follow'
|
||||
defaultMessage='Nobody from this server can follow you.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bullet-points--deemphasized'>
|
||||
<div className='safety-action-modal__bullet-points__icon'>
|
||||
<Icon id='' icon={HistoryIcon} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.they_can_interact_with_old_posts'
|
||||
defaultMessage='People from this server can interact with your old posts.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bottom'>
|
||||
<div className='safety-action-modal__actions'>
|
||||
<Button onClick={handleSecondaryClick} secondary>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.block_account_instead'
|
||||
defaultMessage='Block @{name} instead'
|
||||
values={{ name: acct.split('@')[0] }}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<div className='spacer' />
|
||||
|
||||
<button onClick={handleCancel} className='link-button'>
|
||||
<FormattedMessage
|
||||
id='confirmation_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Button onClick={handleClick} dangerous aria-busy={loading}>
|
||||
{loading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.block'
|
||||
defaultMessage='Block server'
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default DomainBlockModal;
|
||||
@@ -17,7 +17,7 @@ export default class ImageLoader extends PureComponent {
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
onClick: PropTypes.func,
|
||||
zoomButtonHidden: PropTypes.bool,
|
||||
zoomedIn: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -134,7 +134,7 @@ export default class ImageLoader extends PureComponent {
|
||||
};
|
||||
|
||||
render () {
|
||||
const { alt, lang, src, width, height, onClick } = this.props;
|
||||
const { alt, lang, src, width, height, onClick, zoomedIn } = this.props;
|
||||
const { loading } = this.state;
|
||||
|
||||
const className = classNames('image-loader', {
|
||||
@@ -149,6 +149,7 @@ export default class ImageLoader extends PureComponent {
|
||||
<div className='loading-bar__container' style={{ width: this.state.width || width }}>
|
||||
<LoadingBar className='loading-bar' loading={1} />
|
||||
</div>
|
||||
|
||||
<canvas
|
||||
className='image-loader__preview-canvas'
|
||||
ref={this.setCanvasRef}
|
||||
@@ -164,7 +165,7 @@ export default class ImageLoader extends PureComponent {
|
||||
onClick={onClick}
|
||||
width={width}
|
||||
height={height}
|
||||
zoomButtonHidden={this.props.zoomButtonHidden}
|
||||
zoomedIn={zoomedIn}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ import ReactSwipeableViews from 'react-swipeable-views';
|
||||
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 FitScreenIcon from '@/material-icons/400-24px/fit_screen.svg?react';
|
||||
import ActualSizeIcon from '@/svg-icons/actual_size.svg?react';
|
||||
import { getAverageFromBlurhash } from 'flavours/glitch/blurhash';
|
||||
import { GIFV } from 'flavours/glitch/components/gifv';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
@@ -26,6 +28,8 @@ const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||
zoomIn: { id: 'lightbox.zoom_in', defaultMessage: 'Zoom to actual size' },
|
||||
zoomOut: { id: 'lightbox.zoom_out', defaultMessage: 'Zoom to fit' },
|
||||
});
|
||||
|
||||
class MediaModal extends ImmutablePureComponent {
|
||||
@@ -46,30 +50,39 @@ class MediaModal extends ImmutablePureComponent {
|
||||
state = {
|
||||
index: null,
|
||||
navigationHidden: false,
|
||||
zoomButtonHidden: false,
|
||||
zoomedIn: false,
|
||||
};
|
||||
|
||||
handleZoomClick = () => {
|
||||
this.setState(prevState => ({
|
||||
zoomedIn: !prevState.zoomedIn,
|
||||
}));
|
||||
};
|
||||
|
||||
handleSwipe = (index) => {
|
||||
this.setState({ index: index % this.props.media.size });
|
||||
this.setState({
|
||||
index: index % this.props.media.size,
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
handleTransitionEnd = () => {
|
||||
this.setState({
|
||||
zoomButtonHidden: false,
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
handleNextClick = () => {
|
||||
this.setState({
|
||||
index: (this.getIndex() + 1) % this.props.media.size,
|
||||
zoomButtonHidden: true,
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
handlePrevClick = () => {
|
||||
this.setState({
|
||||
index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
|
||||
zoomButtonHidden: true,
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -78,7 +91,7 @@ class MediaModal extends ImmutablePureComponent {
|
||||
|
||||
this.setState({
|
||||
index: index % this.props.media.size,
|
||||
zoomButtonHidden: true,
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -130,15 +143,22 @@ class MediaModal extends ImmutablePureComponent {
|
||||
return this.state.index !== null ? this.state.index : this.props.index;
|
||||
}
|
||||
|
||||
toggleNavigation = () => {
|
||||
handleToggleNavigation = () => {
|
||||
this.setState(prevState => ({
|
||||
navigationHidden: !prevState.navigationHidden,
|
||||
}));
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.setState({
|
||||
viewportWidth: c?.clientWidth,
|
||||
viewportHeight: c?.clientHeight,
|
||||
});
|
||||
};
|
||||
|
||||
render () {
|
||||
const { media, statusId, lang, intl, onClose } = this.props;
|
||||
const { navigationHidden } = this.state;
|
||||
const { navigationHidden, zoomedIn, viewportWidth, viewportHeight } = this.state;
|
||||
|
||||
const index = this.getIndex();
|
||||
|
||||
@@ -160,8 +180,8 @@ class MediaModal extends ImmutablePureComponent {
|
||||
alt={description}
|
||||
lang={lang}
|
||||
key={image.get('url')}
|
||||
onClick={this.toggleNavigation}
|
||||
zoomButtonHidden={this.state.zoomButtonHidden}
|
||||
onClick={this.handleToggleNavigation}
|
||||
zoomedIn={zoomedIn}
|
||||
/>
|
||||
);
|
||||
} else if (image.get('type') === 'video') {
|
||||
@@ -229,9 +249,12 @@ class MediaModal extends ImmutablePureComponent {
|
||||
));
|
||||
}
|
||||
|
||||
const currentMedia = media.get(index);
|
||||
const zoomable = currentMedia.get('type') === 'image' && (currentMedia.getIn(['meta', 'original', 'width']) > viewportWidth || currentMedia.getIn(['meta', 'original', 'height']) > viewportHeight);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal media-modal'>
|
||||
<div className='media-modal__closer' role='presentation' onClick={onClose} >
|
||||
<div className='modal-root__modal media-modal' ref={this.setRef}>
|
||||
<div className='media-modal__closer' role='presentation' onClick={onClose}>
|
||||
<ReactSwipeableViews
|
||||
style={swipeableViewsStyle}
|
||||
containerStyle={containerStyle}
|
||||
@@ -245,7 +268,10 @@ class MediaModal extends ImmutablePureComponent {
|
||||
</div>
|
||||
|
||||
<div className={navigationClassName}>
|
||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={40} />
|
||||
<div className='media-modal__buttons'>
|
||||
{zoomable && <IconButton title={intl.formatMessage(zoomedIn ? messages.zoomOut : messages.zoomIn)} iconComponent={zoomedIn ? FitScreenIcon : ActualSizeIcon} onClick={this.handleZoomClick} />}
|
||||
<IconButton title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} />
|
||||
</div>
|
||||
|
||||
{leftNav}
|
||||
{rightNav}
|
||||
|
||||
@@ -116,6 +116,7 @@ export const MuteModal = ({ accountId, acct }) => {
|
||||
<div className='safety-action-modal__bottom__collapsible'>
|
||||
<div className='safety-action-modal__field-group'>
|
||||
<RadioButtonLabel name='duration' value='0' label={intl.formatMessage(messages.indefinite)} currentValue={muteDuration} onChange={handleChangeMuteDuration} />
|
||||
<RadioButtonLabel name='duration' value='21600' label={intl.formatMessage(messages.hours, { number: 6 })} currentValue={muteDuration} onChange={handleChangeMuteDuration} />
|
||||
<RadioButtonLabel name='duration' value='86400' label={intl.formatMessage(messages.hours, { number: 24 })} currentValue={muteDuration} onChange={handleChangeMuteDuration} />
|
||||
<RadioButtonLabel name='duration' value='604800' label={intl.formatMessage(messages.days, { number: 7 })} currentValue={muteDuration} onChange={handleChangeMuteDuration} />
|
||||
<RadioButtonLabel name='duration' value='2592000' label={intl.formatMessage(messages.days, { number: 30 })} currentValue={muteDuration} onChange={handleChangeMuteDuration} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user