mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-16 17:29:14 +00:00
Compare commits
241 Commits
v4.3.0-bet
...
v4.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
hard: -1
|
||||||
|
|
||||||
libretranslate:
|
libretranslate:
|
||||||
image: libretranslate/libretranslate:v1.6.0
|
image: libretranslate/libretranslate:v1.6.1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- lt-data:/home/libretranslate/.local
|
- lt-data:/home/libretranslate/.local
|
||||||
|
|||||||
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
|
# Create or update the pull request
|
||||||
- name: Create 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:
|
with:
|
||||||
commit-message: 'New Crowdin translations'
|
commit-message: 'New Crowdin translations'
|
||||||
title: 'New Crowdin Translations (automated)'
|
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
|
name: Crowdin / Upload translations
|
||||||
|
|
||||||
on:
|
on:
|
||||||
merge_group:
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- 'main'
|
- 'main'
|
||||||
@@ -32,7 +31,7 @@ jobs:
|
|||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: false
|
upload_translations: false
|
||||||
download_translations: false
|
download_translations: false
|
||||||
crowdin_branch_name: main
|
crowdin_branch_name: ${{ github.base_ref || github.ref_name }}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
||||||
|
|||||||
62
CHANGELOG.md
62
CHANGELOG.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [4.3.0] - UNRELEASED
|
## [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.
|
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 +10,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)\
|
- **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.
|
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
|
- Update dependencies
|
||||||
|
|
||||||
### Added
|
### 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.\
|
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.\
|
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.\
|
As part of this, the visual design of the entire notifications feature has been revamped.\
|
||||||
@@ -27,7 +28,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
|
- `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
|
- `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
|
- `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.\
|
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.\
|
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.\
|
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,7 +61,7 @@ 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)\
|
- **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.\
|
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
|
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.\
|
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:
|
Articles hosted outside the fediverse can indicate a fediverse author with a meta tag:
|
||||||
```html
|
```html
|
||||||
@@ -76,7 +77,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.
|
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)\
|
- **Add support for Redis sentinel** (#31694, #31623, #31744, #31767, and #31768 by @ThisIsMissEm and @oneiros)\
|
||||||
See https://docs.joinmastodon.org/admin/scaling/#redis-sentinel
|
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 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)\
|
- 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
|
This is also exposed through the REST API: https://docs.joinmastodon.org/entities/Instance/#icon
|
||||||
@@ -122,14 +127,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 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 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 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.\
|
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 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.
|
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 validations to `Web::PushSubscription` (#30540 and #30542 by @ThisIsMissEm)
|
||||||
- Add anchors to each authorized application in `/oauth/authorized_applications` (#31677 by @fowl2)
|
- 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 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
|
See https://docs.joinmastodon.org/admin/config/#otel for documentation
|
||||||
- Add API to get multiple accounts and statuses (#27871 and #30465 by @ClearlyClaire)\
|
- 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
|
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 +143,6 @@ The following changelog entries focus on changes visible to users, administrator
|
|||||||
- Add RFC8414 OAuth 2.0 server metadata (#29191 by @ThisIsMissEm)
|
- 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 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 `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 the role ID to the badge component (#29707 by @renchap)
|
||||||
- Add diagnostic message for failure during CLI search deploy (#29462 by @mjankowski)
|
- 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)
|
- Add pagination `Link` headers on API accounts/statuses when pinned true (#29442 by @mjankowski)
|
||||||
@@ -146,10 +150,12 @@ The following changelog entries focus on changes visible to users, administrator
|
|||||||
- Add groundwork for annual reports for accounts (#28693 by @Gargron)\
|
- 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.
|
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 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 new emojis from `jdecked/twemoji` 15.0 (#28404 by @TheEssem)
|
||||||
- Add configurable error handling in attachment batch deletion (#28184 by @vmstan)\
|
- 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).
|
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 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 `nodeName` and `nodeDescription` to nodeinfo `metadata` (#28079 by @6543)
|
||||||
- Add Thai diacritics and tone marks in `HASHTAG_INVALID_CHARS_RE` (#26576 by @ppnplus)
|
- 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)
|
- Add variable delay before link verification of remote account links (#27774 by @ClearlyClaire)
|
||||||
@@ -164,18 +170,18 @@ The following changelog entries focus on changes visible to users, administrator
|
|||||||
|
|
||||||
### Changed
|
### 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.\
|
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.
|
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.\
|
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”.
|
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)\
|
- **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.\
|
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.
|
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 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.
|
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)\
|
- **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.\
|
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 +194,17 @@ The following changelog entries focus on changes visible to users, administrator
|
|||||||
Administrators may need to update their setup accordingly.
|
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 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 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 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 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 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 width of columns in advanced web UI (#31762 by @Gargron)
|
||||||
- Change design of unread conversations in web UI (#31763 by @Gargron)
|
- Change design of unread conversations in web UI (#31763 by @Gargron)
|
||||||
@@ -254,6 +267,7 @@ The following changelog entries focus on changes visible to users, administrator
|
|||||||
|
|
||||||
### Removed
|
### 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 StatsD integration (replaced by OpenTelemetry) (#30240 by @mjankowski)
|
||||||
- Remove `CacheBuster` default options (#30718 by @mjankowski)
|
- Remove `CacheBuster` default options (#30718 by @mjankowski)
|
||||||
- Remove home marker updates from the Web UI (#22721 by @davbeck)\
|
- Remove home marker updates from the Web UI (#22721 by @davbeck)\
|
||||||
@@ -269,9 +283,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 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 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 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 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 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 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 cutoff of instance name in sign-up form (#30598 by @oneiros)
|
||||||
- Fix invalid date searches returning 503 errors (#31526 by @notchairmk)
|
- 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)
|
- Fix invalid `visibility` values in `POST /api/v1/statuses` returning 500 errors (#31571 by @c960657)
|
||||||
@@ -285,10 +312,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 “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 right-to-left text in preview cards (#30930 by @ClearlyClaire)
|
||||||
- Fix rack attack `match_type` value typo in logging config (#30514 by @mjankowski)
|
- 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 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 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 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 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 notification-related records not being reattributed when merging accounts (#29694 by @ClearlyClaire)
|
||||||
- Fix results/query in `api/v1/featured_tags/suggestions` (#29597 by @mjankowski)
|
- Fix results/query in `api/v1/featured_tags/suggestions` (#29597 by @mjankowski)
|
||||||
@@ -298,6 +327,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 full date display not respecting the locale 12/24h format (#29448 by @renchap)
|
||||||
- Fix filters title and keywords overflow (#29396 by @GeopJr)
|
- Fix filters title and keywords overflow (#29396 by @GeopJr)
|
||||||
- Fix incorrect date format in “Follows and followers” (#29390 by @JasonPunyon)
|
- 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 “Edit media” modal sizing and layout when space-constrained (#27095 by @ronilaukkarinen)
|
||||||
- Fix modal container bounds (#29185 by @nico3333fr)
|
- Fix modal container bounds (#29185 by @nico3333fr)
|
||||||
- Fix inefficient HTTP signature parsing using regexps and `StringScanner` (#29133 by @ClearlyClaire)
|
- 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 'csv', '~> 3.2'
|
||||||
gem 'discard', '~> 1.2'
|
gem 'discard', '~> 1.2'
|
||||||
gem 'doorkeeper', '~> 5.6'
|
gem 'doorkeeper', '~> 5.6'
|
||||||
gem 'ed25519', '~> 1.3'
|
|
||||||
gem 'fast_blank', '~> 1.0'
|
gem 'fast_blank', '~> 1.0'
|
||||||
gem 'fastimage'
|
gem 'fastimage'
|
||||||
gem 'hiredis', '~> 0.6'
|
gem 'hiredis', '~> 0.6'
|
||||||
|
|||||||
50
Gemfile.lock
50
Gemfile.lock
@@ -100,20 +100,20 @@ GEM
|
|||||||
attr_required (1.0.2)
|
attr_required (1.0.2)
|
||||||
awrence (1.2.1)
|
awrence (1.2.1)
|
||||||
aws-eventstream (1.3.0)
|
aws-eventstream (1.3.0)
|
||||||
aws-partitions (1.974.0)
|
aws-partitions (1.978.0)
|
||||||
aws-sdk-core (3.205.0)
|
aws-sdk-core (3.209.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.651.0)
|
aws-partitions (~> 1, >= 1.651.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
aws-sdk-kms (1.91.0)
|
aws-sdk-kms (1.94.0)
|
||||||
aws-sdk-core (~> 3, >= 3.205.0)
|
aws-sdk-core (~> 3, >= 3.207.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.162.0)
|
aws-sdk-s3 (1.166.0)
|
||||||
aws-sdk-core (~> 3, >= 3.205.0)
|
aws-sdk-core (~> 3, >= 3.207.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.9.1)
|
aws-sigv4 (1.10.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
azure-storage-blob (2.0.3)
|
azure-storage-blob (2.0.3)
|
||||||
azure-storage-common (~> 2.0)
|
azure-storage-common (~> 2.0)
|
||||||
@@ -134,7 +134,7 @@ GEM
|
|||||||
bindata (2.5.0)
|
bindata (2.5.0)
|
||||||
binding_of_caller (1.0.1)
|
binding_of_caller (1.0.1)
|
||||||
debug_inspector (>= 1.2.0)
|
debug_inspector (>= 1.2.0)
|
||||||
blurhash (0.1.7)
|
blurhash (0.1.8)
|
||||||
bootsnap (1.18.4)
|
bootsnap (1.18.4)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (6.2.1)
|
brakeman (6.2.1)
|
||||||
@@ -197,7 +197,7 @@ GEM
|
|||||||
railties (>= 4.1.0)
|
railties (>= 4.1.0)
|
||||||
responders
|
responders
|
||||||
warden (~> 1.2.3)
|
warden (~> 1.2.3)
|
||||||
devise-two-factor (5.1.0)
|
devise-two-factor (6.0.0)
|
||||||
activesupport (~> 7.0)
|
activesupport (~> 7.0)
|
||||||
devise (~> 4.0)
|
devise (~> 4.0)
|
||||||
railties (~> 7.0)
|
railties (~> 7.0)
|
||||||
@@ -212,9 +212,8 @@ GEM
|
|||||||
domain_name (0.6.20240107)
|
domain_name (0.6.20240107)
|
||||||
doorkeeper (5.7.1)
|
doorkeeper (5.7.1)
|
||||||
railties (>= 5)
|
railties (>= 5)
|
||||||
dotenv (3.1.2)
|
dotenv (3.1.4)
|
||||||
drb (2.2.1)
|
drb (2.2.1)
|
||||||
ed25519 (1.3.0)
|
|
||||||
elasticsearch (7.17.11)
|
elasticsearch (7.17.11)
|
||||||
elasticsearch-api (= 7.17.11)
|
elasticsearch-api (= 7.17.11)
|
||||||
elasticsearch-transport (= 7.17.11)
|
elasticsearch-transport (= 7.17.11)
|
||||||
@@ -290,7 +289,7 @@ GEM
|
|||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
globalid (1.2.1)
|
globalid (1.2.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
google-protobuf (3.25.4)
|
google-protobuf (3.25.5)
|
||||||
googleapis-common-protos-types (1.15.0)
|
googleapis-common-protos-types (1.15.0)
|
||||||
google-protobuf (>= 3.18, < 5.a)
|
google-protobuf (>= 3.18, < 5.a)
|
||||||
haml (6.3.0)
|
haml (6.3.0)
|
||||||
@@ -348,7 +347,7 @@ GEM
|
|||||||
activesupport (>= 3.0)
|
activesupport (>= 3.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
io-console (0.7.2)
|
io-console (0.7.2)
|
||||||
irb (1.14.0)
|
irb (1.14.1)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
@@ -407,7 +406,7 @@ GEM
|
|||||||
llhttp-ffi (0.5.0)
|
llhttp-ffi (0.5.0)
|
||||||
ffi-compiler (~> 1.0)
|
ffi-compiler (~> 1.0)
|
||||||
rake (~> 13.0)
|
rake (~> 13.0)
|
||||||
logger (1.6.0)
|
logger (1.6.1)
|
||||||
lograge (0.14.0)
|
lograge (0.14.0)
|
||||||
actionpack (>= 4)
|
actionpack (>= 4)
|
||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
@@ -429,7 +428,7 @@ GEM
|
|||||||
addressable (~> 2.5)
|
addressable (~> 2.5)
|
||||||
azure-storage-blob (~> 2.0.1)
|
azure-storage-blob (~> 2.0.1)
|
||||||
hashie (~> 5.0)
|
hashie (~> 5.0)
|
||||||
memory_profiler (1.0.2)
|
memory_profiler (1.1.0)
|
||||||
mime-types (3.5.2)
|
mime-types (3.5.2)
|
||||||
mime-types-data (~> 3.2015)
|
mime-types-data (~> 3.2015)
|
||||||
mime-types-data (3.2024.0820)
|
mime-types-data (3.2024.0820)
|
||||||
@@ -602,7 +601,7 @@ GEM
|
|||||||
actionmailer (>= 3)
|
actionmailer (>= 3)
|
||||||
net-smtp
|
net-smtp
|
||||||
premailer (~> 1.7, >= 1.7.9)
|
premailer (~> 1.7, >= 1.7.9)
|
||||||
propshaft (1.0.0)
|
propshaft (1.1.0)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
@@ -610,7 +609,7 @@ GEM
|
|||||||
psych (5.1.2)
|
psych (5.1.2)
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.1)
|
public_suffix (6.0.1)
|
||||||
puma (6.4.2)
|
puma (6.4.3)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.4.0)
|
pundit (2.4.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
@@ -699,7 +698,7 @@ GEM
|
|||||||
responders (3.1.1)
|
responders (3.1.1)
|
||||||
actionpack (>= 5.2)
|
actionpack (>= 5.2)
|
||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
rexml (3.3.7)
|
rexml (3.3.8)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rouge (4.3.0)
|
rouge (4.3.0)
|
||||||
rpam2 (4.0.2)
|
rpam2 (4.0.2)
|
||||||
@@ -749,15 +748,15 @@ GEM
|
|||||||
parser (>= 3.3.1.0)
|
parser (>= 3.3.1.0)
|
||||||
rubocop-capybara (2.21.0)
|
rubocop-capybara (2.21.0)
|
||||||
rubocop (~> 1.41)
|
rubocop (~> 1.41)
|
||||||
rubocop-performance (1.21.1)
|
rubocop-performance (1.22.1)
|
||||||
rubocop (>= 1.48.1, < 2.0)
|
rubocop (>= 1.48.1, < 2.0)
|
||||||
rubocop-ast (>= 1.31.1, < 2.0)
|
rubocop-ast (>= 1.31.1, < 2.0)
|
||||||
rubocop-rails (2.25.1)
|
rubocop-rails (2.26.2)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.33.0, < 2.0)
|
rubocop (>= 1.52.0, < 2.0)
|
||||||
rubocop-ast (>= 1.31.1, < 2.0)
|
rubocop-ast (>= 1.31.1, < 2.0)
|
||||||
rubocop-rspec (3.0.4)
|
rubocop-rspec (3.0.5)
|
||||||
rubocop (~> 1.61)
|
rubocop (~> 1.61)
|
||||||
rubocop-rspec_rails (2.30.0)
|
rubocop-rspec_rails (2.30.0)
|
||||||
rubocop (~> 1.61)
|
rubocop (~> 1.61)
|
||||||
@@ -782,7 +781,7 @@ GEM
|
|||||||
scenic (1.8.0)
|
scenic (1.8.0)
|
||||||
activerecord (>= 4.0.0)
|
activerecord (>= 4.0.0)
|
||||||
railties (>= 4.0.0)
|
railties (>= 4.0.0)
|
||||||
selenium-webdriver (4.24.0)
|
selenium-webdriver (4.25.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
logger (~> 1.4)
|
logger (~> 1.4)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
@@ -885,7 +884,7 @@ GEM
|
|||||||
webfinger (1.2.0)
|
webfinger (1.2.0)
|
||||||
activesupport
|
activesupport
|
||||||
httpclient (>= 2.4)
|
httpclient (>= 2.4)
|
||||||
webmock (3.23.1)
|
webmock (3.24.0)
|
||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
@@ -894,7 +893,7 @@ GEM
|
|||||||
rack-proxy (>= 0.6.1)
|
rack-proxy (>= 0.6.1)
|
||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
semantic_range (>= 2.3.0)
|
semantic_range (>= 2.3.0)
|
||||||
webrick (1.8.1)
|
webrick (1.8.2)
|
||||||
websocket (1.2.11)
|
websocket (1.2.11)
|
||||||
websocket-driver (0.7.6)
|
websocket-driver (0.7.6)
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
@@ -937,7 +936,6 @@ DEPENDENCIES
|
|||||||
discard (~> 1.2)
|
discard (~> 1.2)
|
||||||
doorkeeper (~> 5.6)
|
doorkeeper (~> 5.6)
|
||||||
dotenv
|
dotenv
|
||||||
ed25519 (~> 1.3)
|
|
||||||
email_spec
|
email_spec
|
||||||
fabrication (~> 2.30)
|
fabrication (~> 2.30)
|
||||||
faker (~> 3.2)
|
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
|
## Supported Versions
|
||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ------- | --------- |
|
| ------- | ---------------- |
|
||||||
| 4.2.x | Yes |
|
| 4.3.x | Yes |
|
||||||
| 4.1.x | Yes |
|
| 4.2.x | Yes |
|
||||||
| < 4.1 | No |
|
| 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) }
|
@items = @items.map { |item| item.distributable? ? item : ActivityPub::TagManager.instance.uri_for(item) }
|
||||||
when 'tags'
|
when 'tags'
|
||||||
@items = for_signed_account { @account.featured_tags }
|
@items = for_signed_account { @account.featured_tags }
|
||||||
when 'devices'
|
|
||||||
@items = @account.devices
|
|
||||||
else
|
else
|
||||||
not_found
|
not_found
|
||||||
end
|
end
|
||||||
@@ -31,7 +29,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
|||||||
|
|
||||||
def set_size
|
def set_size
|
||||||
case params[:id]
|
case params[:id]
|
||||||
when 'featured', 'devices', 'tags'
|
when 'featured', 'tags'
|
||||||
@size = @items.size
|
@size = @items.size
|
||||||
else
|
else
|
||||||
not_found
|
not_found
|
||||||
@@ -42,7 +40,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
|||||||
case params[:id]
|
case params[:id]
|
||||||
when 'featured'
|
when 'featured'
|
||||||
@type = :ordered
|
@type = :ordered
|
||||||
when 'devices', 'tags'
|
when 'tags'
|
||||||
@type = :unordered
|
@type = :unordered
|
||||||
else
|
else
|
||||||
not_found
|
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
|
before_action :set_replies
|
||||||
|
|
||||||
def index
|
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
|
render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true
|
||||||
end
|
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
|
||||||
@@ -7,7 +7,7 @@ class Api::OEmbedController < Api::BaseController
|
|||||||
before_action :require_public_status!
|
before_action :require_public_status!
|
||||||
|
|
||||||
def show
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -23,12 +23,4 @@ class Api::OEmbedController < Api::BaseController
|
|||||||
def status_finder
|
def status_finder
|
||||||
StatusFinder.new(params[:url])
|
StatusFinder.new(params[:url])
|
||||||
end
|
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
|
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
|
||||||
@@ -7,6 +7,8 @@ class Api::V1::Peers::SearchController < Api::BaseController
|
|||||||
skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
|
skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
|
||||||
skip_around_action :set_locale
|
skip_around_action :set_locale
|
||||||
|
|
||||||
|
LIMIT = 10
|
||||||
|
|
||||||
vary_by ''
|
vary_by ''
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@@ -35,10 +37,10 @@ class Api::V1::Peers::SearchController < Api::BaseController
|
|||||||
field: 'accounts_count',
|
field: 'accounts_count',
|
||||||
modifier: 'log2p',
|
modifier: 'log2p',
|
||||||
},
|
},
|
||||||
}).limit(10).pluck(:domain)
|
}).limit(LIMIT).pluck(:domain)
|
||||||
else
|
else
|
||||||
domain = normalized_domain
|
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
|
end
|
||||||
rescue Addressable::URI::InvalidURIError
|
rescue Addressable::URI::InvalidURIError
|
||||||
@domains = []
|
@domains = []
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController
|
|||||||
return not_found if @status.hidden?
|
return not_found if @status.hidden?
|
||||||
|
|
||||||
if @status.local?
|
if @status.local?
|
||||||
render json: @status, serializer: OEmbedSerializer, width: 400
|
render json: @status, serializer: OEmbedSerializer
|
||||||
else
|
else
|
||||||
return not_found unless user_signed_in?
|
return not_found unless user_signed_in?
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
p.form_action(false)
|
p.form_action(false)
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_suspicious!
|
|
||||||
user = find_user
|
|
||||||
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
def create
|
||||||
super do |resource|
|
super do |resource|
|
||||||
# We only need to call this if this hasn't already been
|
# We only need to call this if this hasn't already been
|
||||||
@@ -101,6 +96,11 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def check_suspicious!
|
||||||
|
user = find_user
|
||||||
|
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
||||||
|
end
|
||||||
|
|
||||||
def home_paths(resource)
|
def home_paths(resource)
|
||||||
paths = [about_path, '/explore']
|
paths = [about_path, '/explore']
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ module WebAppControllerConcern
|
|||||||
policy = ContentSecurityPolicy.new
|
policy = ContentSecurityPolicy.new
|
||||||
|
|
||||||
if policy.sso_host.present?
|
if policy.sso_host.present?
|
||||||
p.form_action policy.sso_host
|
p.form_action policy.sso_host, -> { "https://#{request.host}/auth/auth/" }
|
||||||
else
|
else
|
||||||
p.form_action :none
|
p.form_action :none
|
||||||
end
|
end
|
||||||
@@ -31,7 +31,7 @@ module WebAppControllerConcern
|
|||||||
def redirect_unauthenticated_to_permalinks!
|
def redirect_unauthenticated_to_permalinks!
|
||||||
return if user_signed_in? # NOTE: Different from upstream because we allow moved users to log in
|
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?
|
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?
|
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
|
end
|
||||||
|
|
||||||
def create
|
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
|
redirect_to new_settings_two_factor_authentication_confirmation_path
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,7 +7,23 @@ module WellKnown
|
|||||||
def show
|
def show
|
||||||
@webfinger_template = "#{webfinger_url}?resource={uri}"
|
@webfinger_template = "#{webfinger_url}?resource={uri}"
|
||||||
expires_in 3.days, public: true
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -35,4 +35,11 @@ module Admin::ActionLogsHelper
|
|||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ module Admin::DashboardHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def date_range(range)
|
||||||
|
[l(range.first), l(range.last)]
|
||||||
|
.join(' - ')
|
||||||
|
end
|
||||||
|
|
||||||
def relevant_account_timestamp(account)
|
def relevant_account_timestamp(account)
|
||||||
timestamp, exact = if account.user_current_sign_in_at && account.user_current_sign_in_at < 24.hours.ago
|
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]
|
[account.user_current_sign_in_at, true]
|
||||||
@@ -25,6 +30,8 @@ module Admin::DashboardHelper
|
|||||||
[account.user_current_sign_in_at, false]
|
[account.user_current_sign_in_at, false]
|
||||||
elsif account.user_pending?
|
elsif account.user_pending?
|
||||||
[account.user_created_at, true]
|
[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?
|
elsif account.last_status_at.present?
|
||||||
[account.last_status_at, true]
|
[account.last_status_at, true]
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module ApplicationHelper
|
module ApplicationHelper
|
||||||
DANGEROUS_SCOPES = %w(
|
|
||||||
read
|
|
||||||
write
|
|
||||||
follow
|
|
||||||
).freeze
|
|
||||||
|
|
||||||
RTL_LOCALES = %i(
|
RTL_LOCALES = %i(
|
||||||
ar
|
ar
|
||||||
ckb
|
ckb
|
||||||
@@ -95,8 +89,11 @@ module ApplicationHelper
|
|||||||
Rails.env.production? ? site_title : "#{site_title} (Dev)"
|
Rails.env.production? ? site_title : "#{site_title} (Dev)"
|
||||||
end
|
end
|
||||||
|
|
||||||
def class_for_scope(scope)
|
def label_for_scope(scope)
|
||||||
'scope-danger' if DANGEROUS_SCOPES.include?(scope.to_s)
|
safe_join [
|
||||||
|
tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }),
|
||||||
|
tag.span(t("doorkeeper.scopes.#{scope}"), class: :hint),
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def can?(action, record)
|
def can?(action, record)
|
||||||
@@ -244,6 +241,10 @@ module ApplicationHelper
|
|||||||
full_asset_url(instance_presenter.mascot&.file&.url || frontend_asset_path('images/elephant_ui_plane.svg'))
|
full_asset_url(instance_presenter.mascot&.file&.url || frontend_asset_path('images/elephant_ui_plane.svg'))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def copyable_input(options = {})
|
||||||
|
tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options)
|
||||||
|
end
|
||||||
|
|
||||||
# glitch-soc addition to handle the multiple flavors
|
# glitch-soc addition to handle the multiple flavors
|
||||||
def preload_locale_pack
|
def preload_locale_pack
|
||||||
supported_locales = Themes.instance.flavour(current_flavour)['locales']
|
supported_locales = Themes.instance.flavour(current_flavour)['locales']
|
||||||
|
|||||||
@@ -24,23 +24,6 @@ module ContextHelper
|
|||||||
indexable: { 'toot' => 'http://joinmastodon.org/ns#', 'indexable' => 'toot:indexable' },
|
indexable: { 'toot' => 'http://joinmastodon.org/ns#', 'indexable' => 'toot:indexable' },
|
||||||
memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' },
|
memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' },
|
||||||
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
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' },
|
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||||
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|||||||
@@ -10,16 +10,17 @@ module SettingsHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def featured_tags_hint(recently_used_tags)
|
def featured_tags_hint(recently_used_tags)
|
||||||
safe_join(
|
recently_used_tags.present? &&
|
||||||
[
|
safe_join(
|
||||||
t('simple_form.hints.featured_tag.name'),
|
[
|
||||||
safe_join(
|
t('simple_form.hints.featured_tag.name'),
|
||||||
links_for_featured_tags(recently_used_tags),
|
safe_join(
|
||||||
', '
|
links_for_featured_tags(recently_used_tags),
|
||||||
),
|
', '
|
||||||
],
|
),
|
||||||
' '
|
],
|
||||||
)
|
' '
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def session_device_icon(session)
|
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 { browserHistory } from 'flavours/glitch/components/router';
|
||||||
|
import { debounceWithDispatchAndArguments } from 'flavours/glitch/utils/debounce';
|
||||||
|
|
||||||
import api, { getLinks } from '../api';
|
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) {
|
export function fetchRelationships(accountIds) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
@@ -473,13 +488,7 @@ export function fetchRelationships(accountIds) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(fetchRelationshipsRequest(newAccountIds));
|
debouncedFetchRelationships(dispatch, ...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));
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { defineMessages } from 'react-intl';
|
import { defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
||||||
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
|
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);
|
console.error(error);
|
||||||
|
|
||||||
return showAlert({
|
return showAlert({
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ export const synchronouslySubmitMarkers = createAppAsyncThunk(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
} else if ('sendBeacon' in navigator) {
|
||||||
} else if ('navigator' && 'sendBeacon' in navigator) {
|
|
||||||
// Failing that, we can use sendBeacon, but we have to encode the data as
|
// Failing that, we can use sendBeacon, but we have to encode the data as
|
||||||
// FormData for DoorKeeper to recognize the token.
|
// FormData for DoorKeeper to recognize the token.
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|||||||
@@ -68,10 +68,19 @@ function dispatchAssociatedRecords(
|
|||||||
dispatch(importFetchedStatuses(fetchedStatuses));
|
dispatch(importFetchedStatuses(fetchedStatuses));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const supportedGroupedNotificationTypes = ['favourite', 'reblog'];
|
||||||
|
|
||||||
|
export function shouldGroupNotificationType(type: string) {
|
||||||
|
return supportedGroupedNotificationTypes.includes(type);
|
||||||
|
}
|
||||||
|
|
||||||
export const fetchNotifications = createDataLoadingThunk(
|
export const fetchNotifications = createDataLoadingThunk(
|
||||||
'notificationGroups/fetch',
|
'notificationGroups/fetch',
|
||||||
async (_params, { getState }) =>
|
async (_params, { getState }) =>
|
||||||
apiFetchNotificationGroups({ exclude_types: getExcludedTypes(getState()) }),
|
apiFetchNotificationGroups({
|
||||||
|
grouped_types: supportedGroupedNotificationTypes,
|
||||||
|
exclude_types: getExcludedTypes(getState()),
|
||||||
|
}),
|
||||||
({ notifications, accounts, statuses }, { dispatch }) => {
|
({ notifications, accounts, statuses }, { dispatch }) => {
|
||||||
dispatch(importFetchedAccounts(accounts));
|
dispatch(importFetchedAccounts(accounts));
|
||||||
dispatch(importFetchedStatuses(statuses));
|
dispatch(importFetchedStatuses(statuses));
|
||||||
@@ -93,6 +102,7 @@ export const fetchNotificationsGap = createDataLoadingThunk(
|
|||||||
'notificationGroups/fetchGap',
|
'notificationGroups/fetchGap',
|
||||||
async (params: { gap: NotificationGap }, { getState }) =>
|
async (params: { gap: NotificationGap }, { getState }) =>
|
||||||
apiFetchNotificationGroups({
|
apiFetchNotificationGroups({
|
||||||
|
grouped_types: supportedGroupedNotificationTypes,
|
||||||
max_id: params.gap.maxId,
|
max_id: params.gap.maxId,
|
||||||
exclude_types: getExcludedTypes(getState()),
|
exclude_types: getExcludedTypes(getState()),
|
||||||
}),
|
}),
|
||||||
@@ -109,6 +119,7 @@ export const pollRecentNotifications = createDataLoadingThunk(
|
|||||||
'notificationGroups/pollRecentNotifications',
|
'notificationGroups/pollRecentNotifications',
|
||||||
async (_params, { getState }) => {
|
async (_params, { getState }) => {
|
||||||
return apiFetchNotificationGroups({
|
return apiFetchNotificationGroups({
|
||||||
|
grouped_types: supportedGroupedNotificationTypes,
|
||||||
max_id: undefined,
|
max_id: undefined,
|
||||||
exclude_types: getExcludedTypes(getState()),
|
exclude_types: getExcludedTypes(getState()),
|
||||||
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones
|
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ export const updateNotificationsPolicy = createDataLoadingThunk(
|
|||||||
(policy: Partial<NotificationPolicy>) => apiUpdateNotificationsPolicy(policy),
|
(policy: Partial<NotificationPolicy>) => apiUpdateNotificationsPolicy(policy),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const decreasePendingNotificationsCount = createAction<number>(
|
export const decreasePendingRequestsCount = createAction<number>(
|
||||||
'notificationPolicy/decreasePendingNotificationCount',
|
'notificationPolicy/decreasePendingRequestsCount',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import type {
|
|||||||
ApiNotificationJSON,
|
ApiNotificationJSON,
|
||||||
} from 'flavours/glitch/api_types/notifications';
|
} from 'flavours/glitch/api_types/notifications';
|
||||||
import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses';
|
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 { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
|
||||||
|
|
||||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||||
import { decreasePendingNotificationsCount } from './notification_policies';
|
import { decreasePendingRequestsCount } from './notification_policies';
|
||||||
|
|
||||||
// TODO: refactor with notification_groups
|
// TODO: refactor with notification_groups
|
||||||
function dispatchAssociatedRecords(
|
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(
|
export const acceptNotificationRequest = createDataLoadingThunk(
|
||||||
'notificationRequest/accept',
|
'notificationRequest/accept',
|
||||||
({ id }: { id: string }) => apiAcceptNotificationRequest(id),
|
({ id }: { id: string }) => apiAcceptNotificationRequest(id),
|
||||||
(_data, { dispatch, getState, discardLoadData, actionArg: { id } }) => {
|
(_data, { dispatch, discardLoadData }) => {
|
||||||
const count = selectNotificationCountForRequest(getState(), id);
|
dispatch(decreasePendingRequestsCount(1));
|
||||||
|
|
||||||
dispatch(decreasePendingNotificationsCount(count));
|
|
||||||
|
|
||||||
// The payload is not used in any functions
|
// The payload is not used in any functions
|
||||||
return discardLoadData;
|
return discardLoadData;
|
||||||
@@ -191,10 +183,8 @@ export const acceptNotificationRequest = createDataLoadingThunk(
|
|||||||
export const dismissNotificationRequest = createDataLoadingThunk(
|
export const dismissNotificationRequest = createDataLoadingThunk(
|
||||||
'notificationRequest/dismiss',
|
'notificationRequest/dismiss',
|
||||||
({ id }: { id: string }) => apiDismissNotificationRequest(id),
|
({ id }: { id: string }) => apiDismissNotificationRequest(id),
|
||||||
(_data, { dispatch, getState, discardLoadData, actionArg: { id } }) => {
|
(_data, { dispatch, discardLoadData }) => {
|
||||||
const count = selectNotificationCountForRequest(getState(), id);
|
dispatch(decreasePendingRequestsCount(1));
|
||||||
|
|
||||||
dispatch(decreasePendingNotificationsCount(count));
|
|
||||||
|
|
||||||
// The payload is not used in any functions
|
// The payload is not used in any functions
|
||||||
return discardLoadData;
|
return discardLoadData;
|
||||||
@@ -204,13 +194,8 @@ export const dismissNotificationRequest = createDataLoadingThunk(
|
|||||||
export const acceptNotificationRequests = createDataLoadingThunk(
|
export const acceptNotificationRequests = createDataLoadingThunk(
|
||||||
'notificationRequests/acceptBulk',
|
'notificationRequests/acceptBulk',
|
||||||
({ ids }: { ids: string[] }) => apiAcceptNotificationRequests(ids),
|
({ ids }: { ids: string[] }) => apiAcceptNotificationRequests(ids),
|
||||||
(_data, { dispatch, getState, discardLoadData, actionArg: { ids } }) => {
|
(_data, { dispatch, discardLoadData, actionArg: { ids } }) => {
|
||||||
const count = ids.reduce(
|
dispatch(decreasePendingRequestsCount(ids.length));
|
||||||
(count, id) => count + selectNotificationCountForRequest(getState(), id),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
dispatch(decreasePendingNotificationsCount(count));
|
|
||||||
|
|
||||||
// The payload is not used in any functions
|
// The payload is not used in any functions
|
||||||
return discardLoadData;
|
return discardLoadData;
|
||||||
@@ -220,13 +205,8 @@ export const acceptNotificationRequests = createDataLoadingThunk(
|
|||||||
export const dismissNotificationRequests = createDataLoadingThunk(
|
export const dismissNotificationRequests = createDataLoadingThunk(
|
||||||
'notificationRequests/dismissBulk',
|
'notificationRequests/dismissBulk',
|
||||||
({ ids }: { ids: string[] }) => apiDismissNotificationRequests(ids),
|
({ ids }: { ids: string[] }) => apiDismissNotificationRequests(ids),
|
||||||
(_data, { dispatch, getState, discardLoadData, actionArg: { ids } }) => {
|
(_data, { dispatch, discardLoadData, actionArg: { ids } }) => {
|
||||||
const count = ids.reduce(
|
dispatch(decreasePendingRequestsCount(ids.length));
|
||||||
(count, id) => count + selectNotificationCountForRequest(getState(), id),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
dispatch(decreasePendingNotificationsCount(count));
|
|
||||||
|
|
||||||
// The payload is not used in any functions
|
// The payload is not used in any functions
|
||||||
return discardLoadData;
|
return discardLoadData;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import api, { getLinks } from '../api';
|
|||||||
import { unescapeHTML } from '../utils/html';
|
import { unescapeHTML } from '../utils/html';
|
||||||
import { requestNotificationPermission } from '../utils/notifications';
|
import { requestNotificationPermission } from '../utils/notifications';
|
||||||
|
|
||||||
import { fetchFollowRequests, fetchRelationships } from './accounts';
|
import { fetchFollowRequests } from './accounts';
|
||||||
import {
|
import {
|
||||||
importFetchedAccount,
|
importFetchedAccount,
|
||||||
importFetchedAccounts,
|
importFetchedAccounts,
|
||||||
@@ -68,14 +68,6 @@ defineMessages({
|
|||||||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
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 = () => ({
|
export const loadPending = () => ({
|
||||||
type: NOTIFICATIONS_LOAD_PENDING,
|
type: NOTIFICATIONS_LOAD_PENDING,
|
||||||
});
|
});
|
||||||
@@ -118,8 +110,6 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||||||
|
|
||||||
|
|
||||||
dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered}));
|
dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered}));
|
||||||
|
|
||||||
fetchRelatedRelationships(dispatch, [notification]);
|
|
||||||
} else if (playSound && !filtered) {
|
} else if (playSound && !filtered) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: NOTIFICATIONS_UPDATE_NOOP,
|
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(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));
|
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
||||||
fetchRelatedRelationships(dispatch, response.data);
|
|
||||||
dispatch(submitMarkers());
|
dispatch(submitMarkers());
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
dispatch(expandNotificationsFail(error, isLoadingMore));
|
dispatch(expandNotificationsFail(error, isLoadingMore));
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => {
|
|||||||
// eslint-disable-next-line import/no-default-export
|
// eslint-disable-next-line import/no-default-export
|
||||||
export default function api(withAuthorization = true) {
|
export default function api(withAuthorization = true) {
|
||||||
return axios.create({
|
return axios.create({
|
||||||
|
transitional: {
|
||||||
|
clarifyTimeoutError: true,
|
||||||
|
},
|
||||||
headers: {
|
headers: {
|
||||||
...csrfHeader,
|
...csrfHeader,
|
||||||
...(withAuthorization ? authorizationTokenFromInitialState() : {}),
|
...(withAuthorization ? authorizationTokenFromInitialState() : {}),
|
||||||
@@ -67,6 +70,7 @@ export async function apiRequest<ApiResponse = unknown>(
|
|||||||
args: {
|
args: {
|
||||||
params?: RequestParamsOrData;
|
params?: RequestParamsOrData;
|
||||||
data?: RequestParamsOrData;
|
data?: RequestParamsOrData;
|
||||||
|
timeout?: number;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const { data } = await api().request<ApiResponse>({
|
const { data } = await api().request<ApiResponse>({
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const apiFetchNotifications = async (
|
|||||||
|
|
||||||
export const apiFetchNotificationGroups = async (params?: {
|
export const apiFetchNotificationGroups = async (params?: {
|
||||||
url?: string;
|
url?: string;
|
||||||
|
grouped_types?: string[];
|
||||||
exclude_types?: string[];
|
exclude_types?: string[];
|
||||||
max_id?: string;
|
max_id?: string;
|
||||||
since_id?: string;
|
since_id?: string;
|
||||||
@@ -91,5 +92,5 @@ export const apiAcceptNotificationRequests = async (id: string[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const apiDismissNotificationRequests = async (id: string[]) => {
|
export const apiDismissNotificationRequests = async (id: string[]) => {
|
||||||
return apiRequestPost('v1/notifications/dismiss/dismiss', { id });
|
return apiRequestPost('v1/notifications/requests/dismiss', { id });
|
||||||
};
|
};
|
||||||
|
|||||||
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 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 type { Account } from 'flavours/glitch/models/account';
|
||||||
|
|
||||||
import { useHovering } from '../hooks/useHovering';
|
|
||||||
import { autoPlayGif } from '../initial_state';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
|
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
|
||||||
size: number;
|
size: number;
|
||||||
@@ -25,6 +26,8 @@ export const Avatar: React.FC<Props> = ({
|
|||||||
counterBorderColor,
|
counterBorderColor,
|
||||||
}) => {
|
}) => {
|
||||||
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate);
|
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
...styleFromParent,
|
...styleFromParent,
|
||||||
@@ -37,17 +40,29 @@ export const Avatar: React.FC<Props> = ({
|
|||||||
? account?.get('avatar')
|
? account?.get('avatar')
|
||||||
: account?.get('avatar_static');
|
: account?.get('avatar_static');
|
||||||
|
|
||||||
|
const handleLoad = useCallback(() => {
|
||||||
|
setLoading(false);
|
||||||
|
}, [setLoading]);
|
||||||
|
|
||||||
|
const handleError = useCallback(() => {
|
||||||
|
setError(true);
|
||||||
|
}, [setError]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames('account__avatar', {
|
className={classNames('account__avatar', {
|
||||||
'account__avatar-inline': inline,
|
'account__avatar--inline': inline,
|
||||||
|
'account__avatar--loading': loading,
|
||||||
})}
|
})}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
style={style}
|
style={style}
|
||||||
data-avatar-of={account && `@${account.get('acct')}`}
|
data-avatar-of={account && `@${account.get('acct')}`}
|
||||||
>
|
>
|
||||||
{src && <img src={src} alt='' />}
|
{src && !error && (
|
||||||
|
<img src={src} alt='' onLoad={handleLoad} onError={handleError} />
|
||||||
|
)}
|
||||||
|
|
||||||
{counter && (
|
{counter && (
|
||||||
<div
|
<div
|
||||||
className='account__avatar__counter'
|
className='account__avatar__counter'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface BaseProps
|
|||||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||||
block?: boolean;
|
block?: boolean;
|
||||||
secondary?: boolean;
|
secondary?: boolean;
|
||||||
|
dangerous?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PropsChildren extends PropsWithChildren<BaseProps> {
|
interface PropsChildren extends PropsWithChildren<BaseProps> {
|
||||||
@@ -26,6 +27,7 @@ export const Button: React.FC<Props> = ({
|
|||||||
disabled,
|
disabled,
|
||||||
block,
|
block,
|
||||||
secondary,
|
secondary,
|
||||||
|
dangerous,
|
||||||
className,
|
className,
|
||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
@@ -46,6 +48,7 @@ export const Button: React.FC<Props> = ({
|
|||||||
className={classNames('button', className, {
|
className={classNames('button', className, {
|
||||||
'button-secondary': secondary,
|
'button-secondary': secondary,
|
||||||
'button--block': block,
|
'button--block': block,
|
||||||
|
'button--dangerous': dangerous,
|
||||||
})}
|
})}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/* 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='status.show_less' defaultMessage='Show less' />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage id='status.show_more' defaultMessage='Show more' />
|
||||||
|
)}
|
||||||
|
{icons}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
23
app/javascript/flavours/glitch/components/filter_warning.tsx
Normal file
23
app/javascript/flavours/glitch/components/filter_warning.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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.Blue}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
id='filter_warning.matches_filter'
|
||||||
|
defaultMessage='Matches filter “{title}”'
|
||||||
|
values={{ title }}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</StatusBanner>
|
||||||
|
);
|
||||||
@@ -10,7 +10,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||||||
|
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
|
|
||||||
|
import { AltTextBadge } from 'flavours/glitch/components/alt_text_badge';
|
||||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||||
|
import { formatTime } from 'flavours/glitch/features/video';
|
||||||
|
|
||||||
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
|
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@ class Item extends PureComponent {
|
|||||||
|
|
||||||
hoverToPlay () {
|
hoverToPlay () {
|
||||||
const { attachment } = this.props;
|
const { attachment } = this.props;
|
||||||
return !this.getAutoPlay() && attachment.get('type') === 'gifv';
|
return !this.getAutoPlay() && ['gifv', 'video'].includes(attachment.get('type'));
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClick = (e) => {
|
handleClick = (e) => {
|
||||||
@@ -97,7 +99,7 @@ class Item extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (attachment.get('description')?.length > 0) {
|
if (attachment.get('description')?.length > 0) {
|
||||||
badges.push(<span key='alt' className='media-gallery__alt__label'>ALT</span>);
|
badges.push(<AltTextBadge key='alt' description={attachment.get('description')} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
||||||
@@ -152,10 +154,15 @@ class Item extends PureComponent {
|
|||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
} else if (attachment.get('type') === 'gifv') {
|
} else if (['gifv', 'video'].includes(attachment.get('type'))) {
|
||||||
const autoPlay = this.getAutoPlay();
|
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 = (
|
thumbnail = (
|
||||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
||||||
@@ -169,6 +176,7 @@ class Item extends PureComponent {
|
|||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
onMouseEnter={this.handleMouseEnter}
|
onMouseEnter={this.handleMouseEnter}
|
||||||
onMouseLeave={this.handleMouseLeave}
|
onMouseLeave={this.handleMouseLeave}
|
||||||
|
onLoadedData={this.handleImageLoad}
|
||||||
autoPlay={autoPlay}
|
autoPlay={autoPlay}
|
||||||
playsInline
|
playsInline
|
||||||
loop
|
loop
|
||||||
@@ -190,7 +198,7 @@ class Item extends PureComponent {
|
|||||||
|
|
||||||
{visible && thumbnail}
|
{visible && thumbnail}
|
||||||
|
|
||||||
{badges && (
|
{visible && badges && (
|
||||||
<div className='media-gallery__item__badges'>
|
<div className='media-gallery__item__badges'>
|
||||||
{badges}
|
{badges}
|
||||||
</div>
|
</div>
|
||||||
@@ -348,14 +356,14 @@ class MediaGallery extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={computedClass} style={style} ref={this.handleRef}>
|
<div className={computedClass} style={style} ref={this.handleRef}>
|
||||||
|
{children}
|
||||||
|
|
||||||
{(!visible || uncached) && (
|
{(!visible || uncached) && (
|
||||||
<div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
|
<div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
|
||||||
{spoilerButton}
|
{spoilerButton}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{children}
|
|
||||||
|
|
||||||
{(visible && !uncached) && (
|
{(visible && !uncached) && (
|
||||||
<div className='media-gallery__actions'>
|
<div className='media-gallery__actions'>
|
||||||
<button className='media-gallery__actions__pill' onClick={this.handleOpen}><FormattedMessage id='media_gallery.hide' defaultMessage='Hide' /></button>
|
<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 (
|
return (
|
||||||
<div className='modal-root' ref={this.setRef}>
|
<div className='modal-root' ref={this.setRef}>
|
||||||
<div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
|
<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 role='dialog' className='modal-root__container'>{children}</div>
|
||||||
</div>
|
</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 Trends from 'flavours/glitch/features/getting_started/containers/trends_container';
|
||||||
import { showTrends } from 'flavours/glitch/initial_state';
|
import { showTrends } from 'flavours/glitch/initial_state';
|
||||||
|
|
||||||
const DefaultNavigation: React.FC = () =>
|
const DefaultNavigation: React.FC = () => (showTrends ? <Trends /> : null);
|
||||||
showTrends ? (
|
|
||||||
<>
|
|
||||||
<div className='flex-spacer' />
|
|
||||||
<Trends />
|
|
||||||
</>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
export const NavigationPortal: React.FC = () => (
|
export const NavigationPortal: React.FC = () => (
|
||||||
<Switch>
|
<div className='navigation-panel__portal'>
|
||||||
<Route path='/@:acct' exact component={AccountNavigation} />
|
<Switch>
|
||||||
<Route path='/@:acct/tagged/:tagged?' exact component={AccountNavigation} />
|
<Route path='/@:acct' exact component={AccountNavigation} />
|
||||||
<Route path='/@:acct/with_replies' exact component={AccountNavigation} />
|
<Route
|
||||||
<Route path='/@:acct/followers' exact component={AccountNavigation} />
|
path='/@:acct/tagged/:tagged?'
|
||||||
<Route path='/@:acct/following' exact component={AccountNavigation} />
|
exact
|
||||||
<Route path='/@:acct/media' exact component={AccountNavigation} />
|
component={AccountNavigation}
|
||||||
<Route component={DefaultNavigation} />
|
/>
|
||||||
</Switch>
|
<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 (
|
if (
|
||||||
layoutFromWindow() === 'multi-column' &&
|
layoutFromWindow() === 'multi-column' &&
|
||||||
!location.pathname?.startsWith('/deck')
|
location.pathname &&
|
||||||
|
!location.pathname.startsWith('/deck')
|
||||||
) {
|
) {
|
||||||
location.pathname = `/deck${location.pathname}`;
|
location.pathname = `/deck${location.pathname}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -648,6 +648,27 @@ class Status extends ImmutablePureComponent {
|
|||||||
media={status.get('media_attachments')}
|
media={status.get('media_attachments')}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
} else if (['image', 'gifv'].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') {
|
} else if (attachments.getIn([0, 'type']) === 'audio') {
|
||||||
const attachment = status.getIn(['media_attachments', 0]);
|
const attachment = status.getIn(['media_attachments', 0]);
|
||||||
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
||||||
@@ -703,27 +724,6 @@ class Status extends ImmutablePureComponent {
|
|||||||
</Bundle>,
|
</Bundle>,
|
||||||
);
|
);
|
||||||
mediaIcons.push('video-camera');
|
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'])) {
|
if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
|
||||||
|
|||||||
@@ -315,36 +315,48 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filterButton = this.props.onFilter && (
|
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 (
|
return (
|
||||||
<div className='status__action-bar'>
|
<div className='status__action-bar'>
|
||||||
<IconButton
|
<div className='status__action-bar__button-wrapper'>
|
||||||
className='status__action-bar-button'
|
<IconButton
|
||||||
title={replyTitle}
|
className='status__action-bar-button'
|
||||||
icon={replyIcon}
|
title={replyTitle}
|
||||||
iconComponent={replyIconComponent}
|
icon={replyIcon}
|
||||||
onClick={this.handleReplyClick}
|
iconComponent={replyIconComponent}
|
||||||
counter={showReplyCount ? status.get('replies_count') : undefined}
|
onClick={this.handleReplyClick}
|
||||||
obfuscateCount
|
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} />
|
</div>
|
||||||
<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={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}
|
{filterButton}
|
||||||
|
|
||||||
<DropdownMenuContainer
|
<div className='status__action-bar__button-wrapper'>
|
||||||
scrollKey={scrollKey}
|
<DropdownMenuContainer
|
||||||
status={status}
|
scrollKey={scrollKey}
|
||||||
items={menu}
|
status={status}
|
||||||
icon='ellipsis-h'
|
items={menu}
|
||||||
size={18}
|
icon='ellipsis-h'
|
||||||
iconComponent={MoreHorizIcon}
|
size={18}
|
||||||
direction='right'
|
iconComponent={MoreHorizIcon}
|
||||||
ariaLabel={intl.formatMessage(messages.more)}
|
direction='right'
|
||||||
/>
|
ariaLabel={intl.formatMessage(messages.more)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='status__action-bar-spacer' />
|
<div className='status__action-bar-spacer' />
|
||||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
|
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
|
||||||
|
|||||||
37
app/javascript/flavours/glitch/components/status_banner.tsx
Normal file
37
app/javascript/flavours/glitch/components/status_banner.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
export enum BannerVariant {
|
||||||
|
Yellow = 'yellow',
|
||||||
|
Blue = 'blue',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusBanner: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant: BannerVariant;
|
||||||
|
expanded?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}> = ({ children, variant, expanded, onClick }) => (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
variant === BannerVariant.Yellow
|
||||||
|
? 'content-warning'
|
||||||
|
: 'content-warning content-warning--filter'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<button className='link-button' onClick={onClick}>
|
||||||
|
{expanded ? (
|
||||||
|
<FormattedMessage
|
||||||
|
id='content_warning.hide'
|
||||||
|
defaultMessage='Hide post'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormattedMessage
|
||||||
|
id='content_warning.show'
|
||||||
|
defaultMessage='Show anyway'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -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 LinkIcon from '@/material-icons/400-24px/link.svg?react';
|
||||||
import MovieIcon from '@/material-icons/400-24px/movie.svg?react';
|
import MovieIcon from '@/material-icons/400-24px/movie.svg?react';
|
||||||
import MusicNoteIcon from '@/material-icons/400-24px/music_note.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 { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||||
import { autoPlayGif, languages as preloadedLanguages } from 'flavours/glitch/initial_state';
|
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 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 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 language = status.getIn(['translation', 'language']) || status.get('language');
|
||||||
const classNames = classnames('status__content', {
|
const classNames = classnames('status__content', {
|
||||||
'status__content--with-action': parseClick && !disabled,
|
'status__content--with-action': parseClick && !disabled,
|
||||||
@@ -375,45 +376,26 @@ class StatusContent extends PureComponent {
|
|||||||
</Permalink>
|
</Permalink>
|
||||||
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
|
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
|
||||||
|
|
||||||
let toggleText = null;
|
let spoilerIcons = [];
|
||||||
if (hidden) {
|
if (hidden && mediaIcons) {
|
||||||
toggleText = [
|
const mediaComponents = {
|
||||||
<FormattedMessage
|
'link': LinkIcon,
|
||||||
id='status.show_more'
|
'picture-o': ImageIcon,
|
||||||
defaultMessage='Show more'
|
'tasks': InsertChartIcon,
|
||||||
key='0'
|
'video-camera': MovieIcon,
|
||||||
/>,
|
'music': MusicNoteIcon,
|
||||||
];
|
};
|
||||||
if (mediaIcons) {
|
|
||||||
const mediaComponents = {
|
|
||||||
'link': LinkIcon,
|
|
||||||
'picture-o': ImageIcon,
|
|
||||||
'tasks': InsertChartIcon,
|
|
||||||
'video-camera': MovieIcon,
|
|
||||||
'music': MusicNoteIcon,
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaIcons.forEach((mediaIcon, idx) => {
|
spoilerIcons = mediaIcons.map((mediaIcon) => (
|
||||||
toggleText.push(
|
<Icon
|
||||||
<Icon
|
fixedWidth
|
||||||
fixedWidth
|
className='status__content__spoiler-icon'
|
||||||
className='status__content__spoiler-icon'
|
id={mediaIcon}
|
||||||
id={mediaIcon}
|
icon={mediaComponents[mediaIcon]}
|
||||||
icon={mediaComponents[mediaIcon]}
|
aria-hidden='true'
|
||||||
aria-hidden='true'
|
key={`icon-${mediaIcon}`}
|
||||||
key={`icon-${idx}`}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toggleText = (
|
|
||||||
<FormattedMessage
|
|
||||||
id='status.show_less'
|
|
||||||
defaultMessage='Show less'
|
|
||||||
key='0'
|
|
||||||
/>
|
/>
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
@@ -422,15 +404,7 @@ class StatusContent extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
<div className={classNames} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||||
<p
|
<ContentWarning text={spoilerHtml} expanded={!hidden} onClick={this.handleSpoilerClick} icons={spoilerIcons} />
|
||||||
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>
|
|
||||||
|
|
||||||
{mentionsPlaceholder}
|
{mentionsPlaceholder}
|
||||||
|
|
||||||
|
|||||||
@@ -43,10 +43,7 @@ class AccountNavigation extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<FeaturedTags accountId={accountId} tagged={tagged} />
|
||||||
<div className='flex-spacer' />
|
|
||||||
<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 HeaderContainer from '../account_timeline/containers/header_container';
|
||||||
import Column from '../ui/components/column';
|
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 mapStateToProps = (state, { params: { acct, id } }) => {
|
||||||
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
|
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ const messages = defineMessages({
|
|||||||
export const SensitiveButton = () => {
|
export const SensitiveButton = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const spoilersAlwaysOn = useAppSelector((state) => state.getIn(['local_settings', 'always_show_spoilers_field']));
|
const spoilersAlwaysOn = useAppSelector((state) => state.local_settings.getIn(['always_show_spoilers_field']));
|
||||||
const spoilerText = useAppSelector((state) => state.getIn(['compose', 'spoiler_text']));
|
const spoilerText = useAppSelector((state) => state.compose.get('spoiler_text'));
|
||||||
const sensitive = useAppSelector((state) => state.getIn(['compose', 'sensitive']));
|
const sensitive = useAppSelector((state) => state.compose.get('sensitive'));
|
||||||
const spoiler = useAppSelector((state) => state.getIn(['compose', 'spoiler']));
|
const spoiler = useAppSelector((state) => state.compose.get('spoiler'));
|
||||||
const mediaCount = useAppSelector((state) => state.getIn(['compose', 'media_attachments']).size);
|
const mediaCount = useAppSelector((state) => state.compose.get('media_attachments').size);
|
||||||
const disabled = spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler;
|
const disabled = spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler;
|
||||||
|
|
||||||
const active = sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0);
|
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 type { ChangeEventHandler } from 'react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
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 ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
import { useSearchParam } from '../../hooks/useSearchParam';
|
||||||
|
|
||||||
import { AccountCard } from './components/account_card';
|
import { AccountCard } from './components/account_card';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@@ -50,18 +52,19 @@ export const Directory: React.FC<{
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [state, setState] = useState<{
|
|
||||||
order: string | null;
|
|
||||||
local: boolean | null;
|
|
||||||
}>({
|
|
||||||
order: null,
|
|
||||||
local: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const column = useRef<Column>(null);
|
const column = useRef<Column>(null);
|
||||||
|
|
||||||
const order = state.order ?? params?.order ?? 'active';
|
const [orderParam, setOrderParam] = useSearchParam('order');
|
||||||
const local = state.local ?? params?.local ?? false;
|
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(() => {
|
const handlePin = useCallback(() => {
|
||||||
if (columnId) {
|
if (columnId) {
|
||||||
@@ -104,10 +107,10 @@ export const Directory: React.FC<{
|
|||||||
if (columnId) {
|
if (columnId) {
|
||||||
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
|
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
|
||||||
} else {
|
} else {
|
||||||
setState((s) => ({ order: e.target.value, local: s.local }));
|
setOrderParam(e.target.value);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch, columnId],
|
[dispatch, columnId, setOrderParam],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChangeLocal = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
const handleChangeLocal = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||||
@@ -116,11 +119,13 @@ export const Directory: React.FC<{
|
|||||||
dispatch(
|
dispatch(
|
||||||
changeColumnParams(columnId, ['local'], e.target.value === '1'),
|
changeColumnParams(columnId, ['local'], e.target.value === '1'),
|
||||||
);
|
);
|
||||||
|
} else if (e.target.value === '1') {
|
||||||
|
setLocalParam('true');
|
||||||
} else {
|
} else {
|
||||||
setState((s) => ({ local: e.target.value === '1', order: s.order }));
|
setLocalParam('false');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch, columnId],
|
[dispatch, columnId, setLocalParam],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLoadMore = useCallback(() => {
|
const handleLoadMore = useCallback(() => {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const FilteredNotificationsIconButton: React.FC<{
|
|||||||
history.push('/notifications/requests');
|
history.push('/notifications/requests');
|
||||||
}, [history]);
|
}, [history]);
|
||||||
|
|
||||||
if (policy === null || policy.summary.pending_notifications_count === 0) {
|
if (policy === null || policy.summary.pending_requests_count <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export const FilteredNotificationsBanner: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
if (policy === null || policy.summary.pending_notifications_count === 0) {
|
if (policy === null || policy.summary.pending_requests_count <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
|
||||||
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.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 { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
|
import { ContentWarning } from 'flavours/glitch/components/content_warning';
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import type { Status } from 'flavours/glitch/models/status';
|
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';
|
import { EmbeddedStatusContent } from './embedded_status_content';
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const clickCoordinatesRef = useRef<[number, number] | null>();
|
const clickCoordinatesRef = useRef<[number, number] | null>();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const status = useAppSelector(
|
const status = useAppSelector(
|
||||||
(state) => state.statuses.get(statusId) as Status | undefined,
|
(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) {
|
if (!status) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign status attributes to variables with a forced type, as status is not yet properly typed
|
// Assign status attributes to variables with a forced type, as status is not yet properly typed
|
||||||
const contentHtml = status.get('contentHtml') as string;
|
const contentHtml = status.get('contentHtml') as string;
|
||||||
|
const contentWarning = status.get('spoilerHtml') as string;
|
||||||
const poll = status.get('poll');
|
const poll = status.get('poll');
|
||||||
const language = status.get('language') as string;
|
const language = status.get('language') as string;
|
||||||
const mentions = status.get('mentions') as ImmutableList<Mention>;
|
const mentions = status.get('mentions') as ImmutableList<Mention>;
|
||||||
|
const expanded = !status.get('hidden') || !contentWarning;
|
||||||
const mediaAttachmentsSize = (
|
const mediaAttachmentsSize = (
|
||||||
status.get('media_attachments') as ImmutableList<unknown>
|
status.get('media_attachments') as ImmutableList<unknown>
|
||||||
).size;
|
).size;
|
||||||
@@ -124,14 +133,24 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
|||||||
<DisplayName account={account} />
|
<DisplayName account={account} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EmbeddedStatusContent
|
{contentWarning && (
|
||||||
className='notification-group__embedded-status__content reply-indicator__content translate'
|
<ContentWarning
|
||||||
content={contentHtml}
|
text={contentWarning}
|
||||||
language={language}
|
onClick={handleContentWarningClick}
|
||||||
mentions={mentions}
|
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'>
|
<div className='notification-group__embedded-status__attachments reply-indicator__attachments'>
|
||||||
{!!poll && (
|
{!!poll && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Permalink } from 'flavours/glitch/components/permalink';
|
|||||||
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
||||||
import { useAppHistory } from 'flavours/glitch/components/router';
|
import { useAppHistory } from 'flavours/glitch/components/router';
|
||||||
import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon';
|
import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon';
|
||||||
|
import PollContainer from 'flavours/glitch/containers/poll_container';
|
||||||
import { useAppSelector } from 'flavours/glitch/store';
|
import { useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
import { Avatar } from '../../../components/avatar';
|
import { Avatar } from '../../../components/avatar';
|
||||||
@@ -194,6 +195,28 @@ export const DetailedStatus: React.FC<{
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
media.push(<AttachmentList media={status.get('media_attachments')} />);
|
media.push(<AttachmentList media={status.get('media_attachments')} />);
|
||||||
|
} else if (
|
||||||
|
['image', 'gifv'].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') {
|
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||||
const attachment = status.getIn(['media_attachments', 0]);
|
const attachment = status.getIn(['media_attachments', 0]);
|
||||||
const description =
|
const description =
|
||||||
@@ -236,6 +259,7 @@ export const DetailedStatus: React.FC<{
|
|||||||
src={attachment.get('url')}
|
src={attachment.get('url')}
|
||||||
alt={description}
|
alt={description}
|
||||||
lang={language}
|
lang={language}
|
||||||
|
inline
|
||||||
width={300}
|
width={300}
|
||||||
height={150}
|
height={150}
|
||||||
onOpenVideo={handleOpenVideo}
|
onOpenVideo={handleOpenVideo}
|
||||||
@@ -248,23 +272,6 @@ export const DetailedStatus: React.FC<{
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
mediaIcons.push('video-camera');
|
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) {
|
} else if (status.get('spoiler_text').length === 0) {
|
||||||
media.push(
|
media.push(
|
||||||
@@ -277,6 +284,17 @@ export const DetailedStatus: React.FC<{
|
|||||||
mediaIcons.push('link');
|
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')) {
|
if (status.get('application')) {
|
||||||
applicationLink = (
|
applicationLink = (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export const BlockModal = ({ accountId, acct }) => {
|
|||||||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Button onClick={handleClick} autoFocus>
|
<Button onClick={handleClick} dangerous autoFocus>
|
||||||
<FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' />
|
<FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import PropTypes from 'prop-types';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useRouteMatch, NavLink } from 'react-router-dom';
|
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 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 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 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);
|
const activeIconElement = activeIcon ?? (activeIconComponent ? <Icon id={icon} icon={activeIconComponent} className='column-link__icon' /> : iconElement);
|
||||||
@@ -58,6 +58,7 @@ ColumnLink.propTypes = {
|
|||||||
method: PropTypes.string,
|
method: PropTypes.string,
|
||||||
badge: PropTypes.node,
|
badge: PropTypes.node,
|
||||||
transparent: PropTypes.bool,
|
transparent: PropTypes.bool,
|
||||||
|
optional: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ColumnLink;
|
export default ColumnLink;
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { Children, cloneElement, useCallback } from 'react';
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
|
||||||
|
|
||||||
import { scrollRight } from '../../../scroll';
|
import { scrollRight } from '../../../scroll';
|
||||||
import BundleContainer from '../containers/bundle_container';
|
import BundleContainer from '../containers/bundle_container';
|
||||||
import {
|
import {
|
||||||
@@ -65,17 +63,13 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Corresponds to (max-width: $no-gap-breakpoint - 1px) in SCSS
|
// 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 = {
|
state = {
|
||||||
renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
|
renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
if (!this.props.singleColumn) {
|
|
||||||
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.mediaQuery) {
|
if (this.mediaQuery) {
|
||||||
if (this.mediaQuery.addEventListener) {
|
if (this.mediaQuery.addEventListener) {
|
||||||
this.mediaQuery.addEventListener('change', this.handleLayoutChange);
|
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');
|
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 () {
|
componentWillUnmount () {
|
||||||
if (!this.props.singleColumn) {
|
|
||||||
this.node.removeEventListener('wheel', this.handleWheel);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.mediaQuery) {
|
if (this.mediaQuery) {
|
||||||
if (this.mediaQuery.removeEventListener) {
|
if (this.mediaQuery.removeEventListener) {
|
||||||
this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
|
this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
|
||||||
@@ -117,7 +95,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
|||||||
handleChildrenContentChange() {
|
handleChildrenContentChange() {
|
||||||
if (!this.props.singleColumn) {
|
if (!this.props.singleColumn) {
|
||||||
const modifier = this.isRtlLayout ? -1 : 1;
|
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 });
|
this.setState({ renderComposePanel: !e.matches });
|
||||||
};
|
};
|
||||||
|
|
||||||
handleWheel = () => {
|
|
||||||
if (typeof this._interruptScrollAnimation !== 'function') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._interruptScrollAnimation();
|
|
||||||
};
|
|
||||||
|
|
||||||
setRef = (node) => {
|
setRef = (node) => {
|
||||||
this.node = 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,
|
width: PropTypes.number,
|
||||||
height: PropTypes.number,
|
height: PropTypes.number,
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
zoomButtonHidden: PropTypes.bool,
|
zoomedIn: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@@ -134,7 +134,7 @@ export default class ImageLoader extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
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 { loading } = this.state;
|
||||||
|
|
||||||
const className = classNames('image-loader', {
|
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 }}>
|
<div className='loading-bar__container' style={{ width: this.state.width || width }}>
|
||||||
<LoadingBar className='loading-bar' loading={1} />
|
<LoadingBar className='loading-bar' loading={1} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<canvas
|
<canvas
|
||||||
className='image-loader__preview-canvas'
|
className='image-loader__preview-canvas'
|
||||||
ref={this.setCanvasRef}
|
ref={this.setCanvasRef}
|
||||||
@@ -164,7 +165,7 @@ export default class ImageLoader extends PureComponent {
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
zoomButtonHidden={this.props.zoomButtonHidden}
|
zoomedIn={zoomedIn}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import ReactSwipeableViews from 'react-swipeable-views';
|
|||||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.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 { getAverageFromBlurhash } from 'flavours/glitch/blurhash';
|
||||||
import { GIFV } from 'flavours/glitch/components/gifv';
|
import { GIFV } from 'flavours/glitch/components/gifv';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
@@ -26,6 +28,8 @@ const messages = defineMessages({
|
|||||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
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 {
|
class MediaModal extends ImmutablePureComponent {
|
||||||
@@ -46,30 +50,39 @@ class MediaModal extends ImmutablePureComponent {
|
|||||||
state = {
|
state = {
|
||||||
index: null,
|
index: null,
|
||||||
navigationHidden: false,
|
navigationHidden: false,
|
||||||
zoomButtonHidden: false,
|
zoomedIn: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleZoomClick = () => {
|
||||||
|
this.setState(prevState => ({
|
||||||
|
zoomedIn: !prevState.zoomedIn,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleSwipe = (index) => {
|
handleSwipe = (index) => {
|
||||||
this.setState({ index: index % this.props.media.size });
|
this.setState({
|
||||||
|
index: index % this.props.media.size,
|
||||||
|
zoomedIn: false,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
handleTransitionEnd = () => {
|
handleTransitionEnd = () => {
|
||||||
this.setState({
|
this.setState({
|
||||||
zoomButtonHidden: false,
|
zoomedIn: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
handleNextClick = () => {
|
handleNextClick = () => {
|
||||||
this.setState({
|
this.setState({
|
||||||
index: (this.getIndex() + 1) % this.props.media.size,
|
index: (this.getIndex() + 1) % this.props.media.size,
|
||||||
zoomButtonHidden: true,
|
zoomedIn: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
handlePrevClick = () => {
|
handlePrevClick = () => {
|
||||||
this.setState({
|
this.setState({
|
||||||
index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
|
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({
|
this.setState({
|
||||||
index: index % this.props.media.size,
|
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;
|
return this.state.index !== null ? this.state.index : this.props.index;
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleNavigation = () => {
|
handleToggleNavigation = () => {
|
||||||
this.setState(prevState => ({
|
this.setState(prevState => ({
|
||||||
navigationHidden: !prevState.navigationHidden,
|
navigationHidden: !prevState.navigationHidden,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
setRef = c => {
|
||||||
|
this.setState({
|
||||||
|
viewportWidth: c?.clientWidth,
|
||||||
|
viewportHeight: c?.clientHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media, statusId, lang, intl, onClose } = this.props;
|
const { media, statusId, lang, intl, onClose } = this.props;
|
||||||
const { navigationHidden } = this.state;
|
const { navigationHidden, zoomedIn, viewportWidth, viewportHeight } = this.state;
|
||||||
|
|
||||||
const index = this.getIndex();
|
const index = this.getIndex();
|
||||||
|
|
||||||
@@ -160,8 +180,8 @@ class MediaModal extends ImmutablePureComponent {
|
|||||||
alt={description}
|
alt={description}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
key={image.get('url')}
|
key={image.get('url')}
|
||||||
onClick={this.toggleNavigation}
|
onClick={this.handleToggleNavigation}
|
||||||
zoomButtonHidden={this.state.zoomButtonHidden}
|
zoomedIn={zoomedIn}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (image.get('type') === 'video') {
|
} 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 (
|
return (
|
||||||
<div className='modal-root__modal media-modal'>
|
<div className='modal-root__modal media-modal' ref={this.setRef}>
|
||||||
<div className='media-modal__closer' role='presentation' onClick={onClose} >
|
<div className='media-modal__closer' role='presentation' onClick={onClose}>
|
||||||
<ReactSwipeableViews
|
<ReactSwipeableViews
|
||||||
style={swipeableViewsStyle}
|
style={swipeableViewsStyle}
|
||||||
containerStyle={containerStyle}
|
containerStyle={containerStyle}
|
||||||
@@ -245,7 +268,10 @@ class MediaModal extends ImmutablePureComponent {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={navigationClassName}>
|
<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}
|
{leftNav}
|
||||||
{rightNav}
|
{rightNav}
|
||||||
|
|||||||
@@ -122,14 +122,17 @@ class NavigationPanel extends Component {
|
|||||||
|
|
||||||
let banner = undefined;
|
let banner = undefined;
|
||||||
|
|
||||||
if(transientSingleColumn)
|
if (transientSingleColumn) {
|
||||||
banner = (<div className='switch-to-advanced'>
|
banner = (
|
||||||
{intl.formatMessage(messages.openedInClassicInterface)}
|
<div className='switch-to-advanced'>
|
||||||
{" "}
|
{intl.formatMessage(messages.openedInClassicInterface)}
|
||||||
<a href={`/deck${location.pathname}`} className='switch-to-advanced__toggle'>
|
{" "}
|
||||||
{intl.formatMessage(messages.advancedInterface)}
|
<a href={`/deck${location.pathname}`} className='switch-to-advanced__toggle'>
|
||||||
</a>
|
{intl.formatMessage(messages.advancedInterface)}
|
||||||
</div>);
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='navigation-panel'>
|
<div className='navigation-panel'>
|
||||||
@@ -139,55 +142,59 @@ class NavigationPanel extends Component {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
{signedIn && (
|
<div className='navigation-panel__menu'>
|
||||||
<>
|
{signedIn && (
|
||||||
<ColumnLink transparent to='/home' icon='home' iconComponent={HomeIcon} activeIconComponent={HomeActiveIcon} text={intl.formatMessage(messages.home)} />
|
<>
|
||||||
<NotificationsLink />
|
<ColumnLink transparent to='/home' icon='home' iconComponent={HomeIcon} activeIconComponent={HomeActiveIcon} text={intl.formatMessage(messages.home)} />
|
||||||
<FollowRequestsLink />
|
<NotificationsLink />
|
||||||
</>
|
<FollowRequestsLink />
|
||||||
)}
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{trendsEnabled ? (
|
{trendsEnabled ? (
|
||||||
<ColumnLink transparent to='/explore' icon='explore' iconComponent={ExploreIcon} activeIconComponent={ExploreActiveIcon} text={intl.formatMessage(messages.explore)} />
|
<ColumnLink transparent to='/explore' icon='explore' iconComponent={ExploreIcon} activeIconComponent={ExploreActiveIcon} text={intl.formatMessage(messages.explore)} />
|
||||||
) : (
|
) : (
|
||||||
<ColumnLink transparent to='/search' icon='search' iconComponent={SearchIcon} text={intl.formatMessage(messages.search)} />
|
<ColumnLink transparent to='/search' icon='search' iconComponent={SearchIcon} text={intl.formatMessage(messages.search)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(signedIn || timelinePreview) && (
|
{(signedIn || timelinePreview) && (
|
||||||
<ColumnLink transparent to='/public/local' isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
|
<ColumnLink transparent to='/public/local' isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!signedIn && (
|
{!signedIn && (
|
||||||
<div className='navigation-panel__sign-in-banner'>
|
<div className='navigation-panel__sign-in-banner'>
|
||||||
|
<hr />
|
||||||
|
{ disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner /> }
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{signedIn && (
|
||||||
|
<>
|
||||||
|
<ColumnLink transparent to='/conversations' icon='at' iconComponent={MailIcon} activeIconComponent={MailActiveIcon} text={intl.formatMessage(messages.direct)} />
|
||||||
|
<ColumnLink transparent to='/bookmarks' icon='bookmarks' iconComponent={BookmarksIcon} activeIconComponent={BookmarksActiveIcon} text={intl.formatMessage(messages.bookmarks)} />
|
||||||
|
<ColumnLink transparent to='/favourites' icon='star' iconComponent={StarIcon} activeIconComponent={StarActiveIcon} text={intl.formatMessage(messages.favourites)} />
|
||||||
|
<ColumnLink transparent to='/lists' icon='list-ul' iconComponent={ListAltIcon} activeIconComponent={ListAltActiveIcon} text={intl.formatMessage(messages.lists)} />
|
||||||
|
|
||||||
|
<ListPanel />
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{!!preferencesLink && <ColumnLink transparent href={preferencesLink} icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} />}
|
||||||
|
<ColumnLink transparent onClick={onOpenSettings} icon='cogs' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.app_settings)} />
|
||||||
|
|
||||||
|
{canManageReports(permissions) && <ColumnLink optional transparent href='/admin/reports' icon='flag' iconComponent={ModerationIcon} text={intl.formatMessage(messages.moderation)} />}
|
||||||
|
{canViewAdminDashboard(permissions) && <ColumnLink optional transparent href='/admin/dashboard' icon='tachometer' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.administration)} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='navigation-panel__legal'>
|
||||||
<hr />
|
<hr />
|
||||||
{ disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner /> }
|
<ColumnLink transparent to='/about' icon='ellipsis-h' iconComponent={MoreHorizIcon} text={intl.formatMessage(messages.about)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{signedIn && (
|
|
||||||
<>
|
|
||||||
<ColumnLink transparent to='/conversations' icon='at' iconComponent={MailIcon} activeIconComponent={MailActiveIcon} text={intl.formatMessage(messages.direct)} />
|
|
||||||
<ColumnLink transparent to='/bookmarks' icon='bookmarks' iconComponent={BookmarksIcon} activeIconComponent={BookmarksActiveIcon} text={intl.formatMessage(messages.bookmarks)} />
|
|
||||||
<ColumnLink transparent to='/favourites' icon='star' iconComponent={StarIcon} activeIconComponent={StarActiveIcon} text={intl.formatMessage(messages.favourites)} />
|
|
||||||
<ColumnLink transparent to='/lists' icon='list-ul' iconComponent={ListAltIcon} activeIconComponent={ListAltActiveIcon} text={intl.formatMessage(messages.lists)} />
|
|
||||||
|
|
||||||
<ListPanel />
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
{!!preferencesLink && <ColumnLink transparent href={preferencesLink} icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} />}
|
|
||||||
<ColumnLink transparent onClick={onOpenSettings} icon='cogs' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.app_settings)} />
|
|
||||||
|
|
||||||
{canManageReports(permissions) && <ColumnLink transparent href='/admin/reports' icon='flag' iconComponent={ModerationIcon} text={intl.formatMessage(messages.moderation)} />}
|
|
||||||
{canViewAdminDashboard(permissions) && <ColumnLink transparent href='/admin/dashboard' icon='tachometer' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.administration)} />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='navigation-panel__legal'>
|
|
||||||
<hr />
|
|
||||||
<ColumnLink transparent to='/about' icon='ellipsis-h' iconComponent={MoreHorizIcon} text={intl.formatMessage(messages.about)} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='flex-spacer' />
|
||||||
|
|
||||||
<NavigationPortal />
|
<NavigationPortal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import FullscreenExitIcon from '@/material-icons/400-24px/fullscreen_exit.svg?react';
|
|
||||||
import RectangleIcon from '@/material-icons/400-24px/rectangle.svg?react';
|
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
compress: { id: 'lightbox.compress', defaultMessage: 'Compress image view box' },
|
|
||||||
expand: { id: 'lightbox.expand', defaultMessage: 'Expand image view box' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const MIN_SCALE = 1;
|
const MIN_SCALE = 1;
|
||||||
const MAX_SCALE = 4;
|
const MAX_SCALE = 4;
|
||||||
const NAV_BAR_HEIGHT = 66;
|
const NAV_BAR_HEIGHT = 66;
|
||||||
@@ -104,8 +93,7 @@ class ZoomableImage extends PureComponent {
|
|||||||
width: PropTypes.number,
|
width: PropTypes.number,
|
||||||
height: PropTypes.number,
|
height: PropTypes.number,
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
zoomButtonHidden: PropTypes.bool,
|
zoomedIn: PropTypes.bool,
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@@ -131,8 +119,6 @@ class ZoomableImage extends PureComponent {
|
|||||||
translateX: null,
|
translateX: null,
|
||||||
translateY: null,
|
translateY: null,
|
||||||
},
|
},
|
||||||
zoomState: 'expand', // 'expand' 'compress'
|
|
||||||
navigationHidden: false,
|
|
||||||
dragPosition: { top: 0, left: 0, x: 0, y: 0 },
|
dragPosition: { top: 0, left: 0, x: 0, y: 0 },
|
||||||
dragged: false,
|
dragged: false,
|
||||||
lockScroll: { x: 0, y: 0 },
|
lockScroll: { x: 0, y: 0 },
|
||||||
@@ -169,35 +155,20 @@ class ZoomableImage extends PureComponent {
|
|||||||
this.container.addEventListener('DOMMouseScroll', handler);
|
this.container.addEventListener('DOMMouseScroll', handler);
|
||||||
this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
|
this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
|
||||||
|
|
||||||
this.initZoomMatrix();
|
this._initZoomMatrix();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
this.removeEventListeners();
|
this._removeEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate () {
|
componentDidUpdate (prevProps) {
|
||||||
this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' });
|
if (prevProps.zoomedIn !== this.props.zoomedIn) {
|
||||||
|
this._toggleZoom();
|
||||||
if (this.state.scale === MIN_SCALE) {
|
|
||||||
this.container.style.removeProperty('cursor');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
UNSAFE_componentWillReceiveProps () {
|
_removeEventListeners () {
|
||||||
// reset when slide to next image
|
|
||||||
if (this.props.zoomButtonHidden) {
|
|
||||||
this.setState({
|
|
||||||
scale: MIN_SCALE,
|
|
||||||
lockTranslate: { x: 0, y: 0 },
|
|
||||||
}, () => {
|
|
||||||
this.container.scrollLeft = 0;
|
|
||||||
this.container.scrollTop = 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeEventListeners () {
|
|
||||||
this.removers.forEach(listeners => listeners());
|
this.removers.forEach(listeners => listeners());
|
||||||
this.removers = [];
|
this.removers = [];
|
||||||
}
|
}
|
||||||
@@ -220,9 +191,6 @@ class ZoomableImage extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
mouseDownHandler = e => {
|
mouseDownHandler = e => {
|
||||||
this.container.style.cursor = 'grabbing';
|
|
||||||
this.container.style.userSelect = 'none';
|
|
||||||
|
|
||||||
this.setState({ dragPosition: {
|
this.setState({ dragPosition: {
|
||||||
left: this.container.scrollLeft,
|
left: this.container.scrollLeft,
|
||||||
top: this.container.scrollTop,
|
top: this.container.scrollTop,
|
||||||
@@ -246,9 +214,6 @@ class ZoomableImage extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
mouseUpHandler = () => {
|
mouseUpHandler = () => {
|
||||||
this.container.style.cursor = 'grab';
|
|
||||||
this.container.style.removeProperty('user-select');
|
|
||||||
|
|
||||||
this.image.removeEventListener('mousemove', this.mouseMoveHandler);
|
this.image.removeEventListener('mousemove', this.mouseMoveHandler);
|
||||||
this.image.removeEventListener('mouseup', this.mouseUpHandler);
|
this.image.removeEventListener('mouseup', this.mouseUpHandler);
|
||||||
};
|
};
|
||||||
@@ -276,13 +241,13 @@ class ZoomableImage extends PureComponent {
|
|||||||
const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
|
const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
|
||||||
const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
|
const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
|
||||||
|
|
||||||
this.zoom(scale, midpoint);
|
this._zoom(scale, midpoint);
|
||||||
|
|
||||||
this.lastMidpoint = midpoint;
|
this.lastMidpoint = midpoint;
|
||||||
this.lastDistance = distance;
|
this.lastDistance = distance;
|
||||||
};
|
};
|
||||||
|
|
||||||
zoom(nextScale, midpoint) {
|
_zoom(nextScale, midpoint) {
|
||||||
const { scale, zoomMatrix } = this.state;
|
const { scale, zoomMatrix } = this.state;
|
||||||
const { scrollLeft, scrollTop } = this.container;
|
const { scrollLeft, scrollTop } = this.container;
|
||||||
|
|
||||||
@@ -318,14 +283,13 @@ class ZoomableImage extends PureComponent {
|
|||||||
if (dragged) return;
|
if (dragged) return;
|
||||||
const handler = this.props.onClick;
|
const handler = this.props.onClick;
|
||||||
if (handler) handler();
|
if (handler) handler();
|
||||||
this.setState({ navigationHidden: !this.state.navigationHidden });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMouseDown = e => {
|
handleMouseDown = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
initZoomMatrix = () => {
|
_initZoomMatrix = () => {
|
||||||
const { width, height } = this.props;
|
const { width, height } = this.props;
|
||||||
const { clientWidth, clientHeight } = this.container;
|
const { clientWidth, clientHeight } = this.container;
|
||||||
const { offsetWidth, offsetHeight } = this.image;
|
const { offsetWidth, offsetHeight } = this.image;
|
||||||
@@ -357,10 +321,7 @@ class ZoomableImage extends PureComponent {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
handleZoomClick = e => {
|
_toggleZoom () {
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const { scale, zoomMatrix } = this.state;
|
const { scale, zoomMatrix } = this.state;
|
||||||
|
|
||||||
if ( scale >= zoomMatrix.rate ) {
|
if ( scale >= zoomMatrix.rate ) {
|
||||||
@@ -394,10 +355,7 @@ class ZoomableImage extends PureComponent {
|
|||||||
this.container.scrollTop = zoomMatrix.scrollTop;
|
this.container.scrollTop = zoomMatrix.scrollTop;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
this.container.style.cursor = 'grab';
|
|
||||||
this.container.style.removeProperty('user-select');
|
|
||||||
};
|
|
||||||
|
|
||||||
setContainerRef = c => {
|
setContainerRef = c => {
|
||||||
this.container = c;
|
this.container = c;
|
||||||
@@ -408,52 +366,37 @@ class ZoomableImage extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { alt, lang, src, width, height, intl } = this.props;
|
const { alt, lang, src, width, height } = this.props;
|
||||||
const { scale, lockTranslate } = this.state;
|
const { scale, lockTranslate, dragged } = this.state;
|
||||||
const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
|
const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
|
||||||
const zoomButtonShouldHide = this.state.navigationHidden || this.props.zoomButtonHidden || this.state.zoomMatrix.rate <= MIN_SCALE ? 'media-modal__zoom-button--hidden' : '';
|
const cursor = scale === MIN_SCALE ? null : (dragged ? 'grabbing' : 'grab');
|
||||||
const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
<IconButton
|
className='zoomable-image'
|
||||||
className={`media-modal__zoom-button ${zoomButtonShouldHide}`}
|
ref={this.setContainerRef}
|
||||||
title={zoomButtonTitle}
|
style={{ overflow, cursor, userSelect: 'none' }}
|
||||||
icon={this.state.zoomState}
|
>
|
||||||
iconComponent={this.state.zoomState === 'compress' ? FullscreenExitIcon : RectangleIcon}
|
<img
|
||||||
onClick={this.handleZoomClick}
|
role='presentation'
|
||||||
size={40}
|
ref={this.setImageRef}
|
||||||
|
alt={alt}
|
||||||
|
title={alt}
|
||||||
|
lang={lang}
|
||||||
|
src={src}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
style={{
|
style={{
|
||||||
fontSize: '30px', /* Fontawesome's fa-compress fa-expand is larger than fa-close */
|
transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
|
||||||
|
transformOrigin: '0 0',
|
||||||
}}
|
}}
|
||||||
|
draggable={false}
|
||||||
|
onClick={this.handleClick}
|
||||||
|
onMouseDown={this.handleMouseDown}
|
||||||
/>
|
/>
|
||||||
<div
|
</div>
|
||||||
className='zoomable-image'
|
|
||||||
ref={this.setContainerRef}
|
|
||||||
style={{ overflow }}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
role='presentation'
|
|
||||||
ref={this.setImageRef}
|
|
||||||
alt={alt}
|
|
||||||
title={alt}
|
|
||||||
lang={lang}
|
|
||||||
src={src}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
style={{
|
|
||||||
transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
|
|
||||||
transformOrigin: '0 0',
|
|
||||||
}}
|
|
||||||
draggable={false}
|
|
||||||
onClick={this.handleClick}
|
|
||||||
onMouseDown={this.handleMouseDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default injectIntl(ZoomableImage);
|
export default ZoomableImage;
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ class SwitchingColumnsArea extends PureComponent {
|
|||||||
{redirect}
|
{redirect}
|
||||||
|
|
||||||
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
|
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
|
||||||
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={pathName.slice(5)} /> : null}
|
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : null}
|
||||||
{/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */}
|
{/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */}
|
||||||
{!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null}
|
{!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null}
|
||||||
{!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null}
|
{!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null}
|
||||||
|
|||||||
31
app/javascript/flavours/glitch/hooks/useSearchParam.ts
Normal file
31
app/javascript/flavours/glitch/hooks/useSearchParam.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useMemo, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useLocation, useHistory } from 'react-router';
|
||||||
|
|
||||||
|
export function useSearchParams() {
|
||||||
|
const { search } = useLocation();
|
||||||
|
|
||||||
|
return useMemo(() => new URLSearchParams(search), [search]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearchParam(name: string, defaultValue?: string) {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const value = searchParams.get(name) ?? defaultValue;
|
||||||
|
|
||||||
|
const setValue = useCallback(
|
||||||
|
(value: string | null) => {
|
||||||
|
if (value === null) {
|
||||||
|
searchParams.delete(name);
|
||||||
|
} else {
|
||||||
|
searchParams.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
history.push({ search: searchParams.toString() });
|
||||||
|
},
|
||||||
|
[history, name, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [value, setValue] as const;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"about.fork_disclaimer": "جلتش-سوك هو برنامج حر مفتوح المصدر متفرع عن ماستدون.",
|
"about.fork_disclaimer": "جلتش-سوك هو برنامج حر مفتوح المصدر متفرع عن ماستدون.",
|
||||||
"account.disclaimer_full": "قد لا تعكِس المعلومات أدناه كامل الملف الشخصي للمستخدِم.",
|
"account.disclaimer_full": "قد لا تعكِس المعلومات أدناه كامل الملف الشخصي للمستخدِم.",
|
||||||
"account.follows": "يتابِع",
|
"account.follows": "يتابِع",
|
||||||
|
"account.follows_you": "Follows you",
|
||||||
"account.suspended_disclaimer_full": "تم تعليق هذا المستخدم من قبل المشرف.",
|
"account.suspended_disclaimer_full": "تم تعليق هذا المستخدم من قبل المشرف.",
|
||||||
"account.view_full_profile": "عرض الملف الشخصي كاملاً",
|
"account.view_full_profile": "عرض الملف الشخصي كاملاً",
|
||||||
"boost_modal.missing_description": "هذا المنشور يحتوي على وسائط بلا وصف",
|
"boost_modal.missing_description": "هذا المنشور يحتوي على وسائط بلا وصف",
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
"column_subheading.lists": "القوائم",
|
"column_subheading.lists": "القوائم",
|
||||||
"column_subheading.navigation": "التنقل",
|
"column_subheading.navigation": "التنقل",
|
||||||
"community.column_settings.allow_local_only": "إظهار المنشورات المحلية فقط",
|
"community.column_settings.allow_local_only": "إظهار المنشورات المحلية فقط",
|
||||||
|
"compose.attach.doodle": "الرسوم و التخمين",
|
||||||
"compose.change_federation": "تغيير اعدادات الفيديرالية",
|
"compose.change_federation": "تغيير اعدادات الفيديرالية",
|
||||||
"compose.content-type.change": "تغيير خيارات التنسيق المتقدمة",
|
"compose.content-type.change": "تغيير خيارات التنسيق المتقدمة",
|
||||||
"compose.content-type.html": "HTML",
|
"compose.content-type.html": "HTML",
|
||||||
@@ -26,13 +28,8 @@
|
|||||||
"confirmations.deprecated_settings.message": "تم استبدال بعض من الجهاز الخاص بالماستدون {preferences} الذي تستخدمه {app_settings} الخاص بجهاز ماستدون سيتم تجاوزه:",
|
"confirmations.deprecated_settings.message": "تم استبدال بعض من الجهاز الخاص بالماستدون {preferences} الذي تستخدمه {app_settings} الخاص بجهاز ماستدون سيتم تجاوزه:",
|
||||||
"confirmations.missing_media_description.confirm": "أرسل على أيّة حال",
|
"confirmations.missing_media_description.confirm": "أرسل على أيّة حال",
|
||||||
"confirmations.missing_media_description.edit": "تعديل الوسائط",
|
"confirmations.missing_media_description.edit": "تعديل الوسائط",
|
||||||
"confirmations.unfilter.author": "المؤلف",
|
|
||||||
"confirmations.unfilter.confirm": "عرض",
|
|
||||||
"confirmations.unfilter.edit_filter": "تعديل عامل التصفية",
|
|
||||||
"confirmations.unfilter.filters": "مطابقة {count, plural, zero {}one {فلتر} two {فلاتر} few {فلاتر} many {فلاتر} other {فلاتر}}",
|
|
||||||
"direct.group_by_conversations": "تجميع حسب المحادثة",
|
"direct.group_by_conversations": "تجميع حسب المحادثة",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "الحسابات المميزة",
|
"endorsed_accounts_editor.endorsed_accounts": "الحسابات المميزة",
|
||||||
"favourite_modal.combo": "يُمكنك الضّغط على {combo} لتخطي هذا في المرة المُقبلة",
|
|
||||||
"federation.federated.long": "السماح لهذا المنشور الوصول إلى خوادم أخرى",
|
"federation.federated.long": "السماح لهذا المنشور الوصول إلى خوادم أخرى",
|
||||||
"federation.local_only.long": "منع هذا المنشور من الوصول إلى الخوادم الأخرى",
|
"federation.local_only.long": "منع هذا المنشور من الوصول إلى الخوادم الأخرى",
|
||||||
"federation.local_only.short": "محلي فقط",
|
"federation.local_only.short": "محلي فقط",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
"confirmation_modal.do_not_ask_again": "Příště se už neptat",
|
"confirmation_modal.do_not_ask_again": "Příště se už neptat",
|
||||||
"direct.group_by_conversations": "Seskupit do konverzací",
|
"direct.group_by_conversations": "Seskupit do konverzací",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Vybrané účty",
|
"endorsed_accounts_editor.endorsed_accounts": "Vybrané účty",
|
||||||
"favourite_modal.combo": "Příště můžete pro přeskočení stisknout {combo}",
|
|
||||||
"home.column_settings.advanced": "Pokročilé",
|
"home.column_settings.advanced": "Pokročilé",
|
||||||
"home.column_settings.filter_regex": "Filtrovat podle regulárních výrazů",
|
"home.column_settings.filter_regex": "Filtrovat podle regulárních výrazů",
|
||||||
"home.column_settings.show_direct": "Zobrazit přímé zprávy",
|
"home.column_settings.show_direct": "Zobrazit přímé zprávy",
|
||||||
|
|||||||
@@ -18,9 +18,6 @@
|
|||||||
"compose.content-type.plain": "Testun plaen",
|
"compose.content-type.plain": "Testun plaen",
|
||||||
"confirmations.missing_media_description.confirm": "Anfon beth bynnag",
|
"confirmations.missing_media_description.confirm": "Anfon beth bynnag",
|
||||||
"confirmations.missing_media_description.edit": "Golygu cyfryngau",
|
"confirmations.missing_media_description.edit": "Golygu cyfryngau",
|
||||||
"confirmations.unfilter.author": "Awdur",
|
|
||||||
"confirmations.unfilter.confirm": "Dangos",
|
|
||||||
"confirmations.unfilter.edit_filter": "Golygi hidlydd",
|
|
||||||
"settings.content_warnings": "Content warnings",
|
"settings.content_warnings": "Content warnings",
|
||||||
"settings.preferences": "Preferences"
|
"settings.preferences": "Preferences"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,13 +34,9 @@
|
|||||||
"confirmations.missing_media_description.confirm": "Trotzdem absenden",
|
"confirmations.missing_media_description.confirm": "Trotzdem absenden",
|
||||||
"confirmations.missing_media_description.edit": "Anhänge bearbeiten",
|
"confirmations.missing_media_description.edit": "Anhänge bearbeiten",
|
||||||
"confirmations.missing_media_description.message": "Mindestens einem Anhang fehlt eine Beschreibung. Denke darüber nach, alle Anhänge für Sehbeeinträchtigte zu beschreiben, bevor du den Toot absendest.",
|
"confirmations.missing_media_description.message": "Mindestens einem Anhang fehlt eine Beschreibung. Denke darüber nach, alle Anhänge für Sehbeeinträchtigte zu beschreiben, bevor du den Toot absendest.",
|
||||||
"confirmations.unfilter.author": "Urheber",
|
|
||||||
"confirmations.unfilter.confirm": "Anzeigen",
|
|
||||||
"confirmations.unfilter.edit_filter": "Filter bearbeiten",
|
|
||||||
"confirmations.unfilter.filters": "Passende{count, plural, one {r} other {}} Filter",
|
|
||||||
"direct.group_by_conversations": "Nach Unterhaltung gruppieren",
|
"direct.group_by_conversations": "Nach Unterhaltung gruppieren",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Empfohlene Konten",
|
"endorsed_accounts_editor.endorsed_accounts": "Empfohlene Konten",
|
||||||
"favourite_modal.combo": "Mit {combo} wird dieses Fenster beim nächsten Mal nicht mehr angezeigt",
|
"favourite_modal.favourite": "Beitrag favorisieren?",
|
||||||
"federation.federated.long": "Erlaube diesem Beitrag, andere Server zu erreichen",
|
"federation.federated.long": "Erlaube diesem Beitrag, andere Server zu erreichen",
|
||||||
"federation.federated.short": "Föderiert",
|
"federation.federated.short": "Föderiert",
|
||||||
"federation.local_only.long": "Verhindere, dass dieser Post andere Server erreicht",
|
"federation.local_only.long": "Verhindere, dass dieser Post andere Server erreicht",
|
||||||
@@ -128,6 +124,7 @@
|
|||||||
"settings.shared_settings_link": "Nutzereinstellungen",
|
"settings.shared_settings_link": "Nutzereinstellungen",
|
||||||
"settings.show_action_bar": "Aktions-Knöpfe in eingeklappten Toots anzeigen",
|
"settings.show_action_bar": "Aktions-Knöpfe in eingeklappten Toots anzeigen",
|
||||||
"settings.show_content_type_choice": "Auswahl für die Inhaltsart beim Verfassen von Toots anzeigen",
|
"settings.show_content_type_choice": "Auswahl für die Inhaltsart beim Verfassen von Toots anzeigen",
|
||||||
|
"settings.show_published_toast": "Meldung beim Veröffentlichen/Speichern eines Beitrags anzeigen",
|
||||||
"settings.show_reply_counter": "Schätzung der Antwortanzahl anzeigen",
|
"settings.show_reply_counter": "Schätzung der Antwortanzahl anzeigen",
|
||||||
"settings.side_arm": "Sekundärer Toot-Knopf:",
|
"settings.side_arm": "Sekundärer Toot-Knopf:",
|
||||||
"settings.side_arm.none": "Nichts",
|
"settings.side_arm.none": "Nichts",
|
||||||
@@ -147,12 +144,17 @@
|
|||||||
"settings.wide_view": "Breite Ansicht (nur für den Desktop-Modus)",
|
"settings.wide_view": "Breite Ansicht (nur für den Desktop-Modus)",
|
||||||
"settings.wide_view_hint": "Verbreitert Spalten, um den verfügbaren Platz besser zu füllen.",
|
"settings.wide_view_hint": "Verbreitert Spalten, um den verfügbaren Platz besser zu füllen.",
|
||||||
"status.collapse": "Einklappen",
|
"status.collapse": "Einklappen",
|
||||||
|
"status.filtered": "Gefiltert",
|
||||||
"status.has_audio": "Hat angehängte Audiodateien",
|
"status.has_audio": "Hat angehängte Audiodateien",
|
||||||
"status.has_pictures": "Hat angehängte Bilder",
|
"status.has_pictures": "Hat angehängte Bilder",
|
||||||
"status.has_preview_card": "Hat eine Vorschaukarte",
|
"status.has_preview_card": "Hat eine Vorschaukarte",
|
||||||
"status.has_video": "Hat angehängte Videos",
|
"status.has_video": "Hat angehängte Videos",
|
||||||
|
"status.hide": "Beitrag ausblenden",
|
||||||
"status.in_reply_to": "Dieser Toot ist eine Antwort",
|
"status.in_reply_to": "Dieser Toot ist eine Antwort",
|
||||||
"status.is_poll": "Dieser Toot ist eine Umfrage",
|
"status.is_poll": "Dieser Toot ist eine Umfrage",
|
||||||
"status.local_only": "Nur auf deiner Instanz sichtbar",
|
"status.local_only": "Nur auf deiner Instanz sichtbar",
|
||||||
|
"status.show_filter_reason": "Trotzdem anzeigen",
|
||||||
|
"status.show_less": "Weniger anzeigen",
|
||||||
|
"status.show_more": "Mehr anzeigen",
|
||||||
"status.uncollapse": "Ausklappen"
|
"status.uncollapse": "Ausklappen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,6 @@
|
|||||||
"confirmations.missing_media_description.confirm": "Sendi ĉiuokaze",
|
"confirmations.missing_media_description.confirm": "Sendi ĉiuokaze",
|
||||||
"confirmations.missing_media_description.edit": "Redakti aŭdovidaĵon",
|
"confirmations.missing_media_description.edit": "Redakti aŭdovidaĵon",
|
||||||
"confirmations.missing_media_description.message": "Unu aŭ pli da plurmedioj mankas priskribo. Bonvolu priskribi ĉiujn plurmediojn por la vida-malkapabluloj antaŭ ol sendi vian afiŝon.",
|
"confirmations.missing_media_description.message": "Unu aŭ pli da plurmedioj mankas priskribo. Bonvolu priskribi ĉiujn plurmediojn por la vida-malkapabluloj antaŭ ol sendi vian afiŝon.",
|
||||||
"confirmations.unfilter.author": "Aŭtoro",
|
|
||||||
"confirmations.unfilter.confirm": "Montri",
|
|
||||||
"confirmations.unfilter.edit_filter": "Redakti filtrilon",
|
|
||||||
"confirmations.unfilter.filters": "{count, plural, one {# filtrilo} other {# filtriloj}} kongruas",
|
|
||||||
"home.column_settings.filter_regex": "Filtri per regulaj esprimoj",
|
"home.column_settings.filter_regex": "Filtri per regulaj esprimoj",
|
||||||
"navigation_bar.keyboard_shortcuts": "Fulmoklavoj",
|
"navigation_bar.keyboard_shortcuts": "Fulmoklavoj",
|
||||||
"notification_purge.btn_all": "Selekti ĉiujn",
|
"notification_purge.btn_all": "Selekti ĉiujn",
|
||||||
|
|||||||
@@ -34,13 +34,9 @@
|
|||||||
"confirmations.missing_media_description.confirm": "Enviar de todos modos",
|
"confirmations.missing_media_description.confirm": "Enviar de todos modos",
|
||||||
"confirmations.missing_media_description.edit": "Editar medios",
|
"confirmations.missing_media_description.edit": "Editar medios",
|
||||||
"confirmations.missing_media_description.message": "Al menos a un adjunto le falta una descripción. Considera describir toda la multimedia para los débiles visuales antes de mandar el toot.",
|
"confirmations.missing_media_description.message": "Al menos a un adjunto le falta una descripción. Considera describir toda la multimedia para los débiles visuales antes de mandar el toot.",
|
||||||
"confirmations.unfilter.author": "Publicado por",
|
|
||||||
"confirmations.unfilter.confirm": "Mostrar",
|
|
||||||
"confirmations.unfilter.edit_filter": "Editar filtro",
|
|
||||||
"confirmations.unfilter.filters": "Coincidencia con {count, plural, one {filtro} other {filtros}}",
|
|
||||||
"direct.group_by_conversations": "Agrupar por conversación",
|
"direct.group_by_conversations": "Agrupar por conversación",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Cuentas destacadas",
|
"endorsed_accounts_editor.endorsed_accounts": "Cuentas destacadas",
|
||||||
"favourite_modal.combo": "Puedes presionar {combo} para omitir esto la próxima vez",
|
"favourite_modal.favourite": "¿Marcar mensaje como favorito?",
|
||||||
"federation.federated.long": "Permitir que este mensaje llegue a otros servidores",
|
"federation.federated.long": "Permitir que este mensaje llegue a otros servidores",
|
||||||
"federation.federated.short": "Federado",
|
"federation.federated.short": "Federado",
|
||||||
"federation.local_only.long": "Evitar que este mensaje llegue a otros servidores",
|
"federation.local_only.long": "Evitar que este mensaje llegue a otros servidores",
|
||||||
@@ -148,12 +144,17 @@
|
|||||||
"settings.wide_view": "Vista amplia (solo modo de escritorio)",
|
"settings.wide_view": "Vista amplia (solo modo de escritorio)",
|
||||||
"settings.wide_view_hint": "Expande las columnas para llenar mejor el espacio disponible.",
|
"settings.wide_view_hint": "Expande las columnas para llenar mejor el espacio disponible.",
|
||||||
"status.collapse": "Colapsar",
|
"status.collapse": "Colapsar",
|
||||||
|
"status.filtered": "Filtrado",
|
||||||
"status.has_audio": "Contiene archivos de audio",
|
"status.has_audio": "Contiene archivos de audio",
|
||||||
"status.has_pictures": "Contiene imágenes adjuntas",
|
"status.has_pictures": "Contiene imágenes adjuntas",
|
||||||
"status.has_preview_card": "Contiene una tarjeta de vista previa adjunta",
|
"status.has_preview_card": "Contiene una tarjeta de vista previa adjunta",
|
||||||
"status.has_video": "Contiene videos adjuntos",
|
"status.has_video": "Contiene videos adjuntos",
|
||||||
|
"status.hide": "Ocultar mensaje",
|
||||||
"status.in_reply_to": "Esta publicación es una respuesta",
|
"status.in_reply_to": "Esta publicación es una respuesta",
|
||||||
"status.is_poll": "Esta publicación es una encuesta",
|
"status.is_poll": "Esta publicación es una encuesta",
|
||||||
"status.local_only": "Sólo visible para tu instancia",
|
"status.local_only": "Sólo visible para tu instancia",
|
||||||
|
"status.show_filter_reason": "Mostrar de todos modos",
|
||||||
|
"status.show_less": "Mostrar menos",
|
||||||
|
"status.show_more": "Mostrar más",
|
||||||
"status.uncollapse": "Descolapsar"
|
"status.uncollapse": "Descolapsar"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,13 +31,8 @@
|
|||||||
"confirmations.missing_media_description.confirm": "Enviar de todos modos",
|
"confirmations.missing_media_description.confirm": "Enviar de todos modos",
|
||||||
"confirmations.missing_media_description.edit": "Editar medios",
|
"confirmations.missing_media_description.edit": "Editar medios",
|
||||||
"confirmations.missing_media_description.message": "Al menos a un adjunto le falta una descripción. Considera describir toda la multimedia para los débiles visuales antes de mandar el toot.",
|
"confirmations.missing_media_description.message": "Al menos a un adjunto le falta una descripción. Considera describir toda la multimedia para los débiles visuales antes de mandar el toot.",
|
||||||
"confirmations.unfilter.author": "Publicado por",
|
|
||||||
"confirmations.unfilter.confirm": "Mostrar",
|
|
||||||
"confirmations.unfilter.edit_filter": "Editar filtro",
|
|
||||||
"confirmations.unfilter.filters": "Coincidencia con {count, plural, one {filtro} other {filtros}}",
|
|
||||||
"direct.group_by_conversations": "Agrupar por conversación",
|
"direct.group_by_conversations": "Agrupar por conversación",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Cuentas destacadas",
|
"endorsed_accounts_editor.endorsed_accounts": "Cuentas destacadas",
|
||||||
"favourite_modal.combo": "Puedes presionar {combo} para omitir esto la próxima vez",
|
|
||||||
"federation.federated.long": "Permitir que esta publicación llegue a otros servidores",
|
"federation.federated.long": "Permitir que esta publicación llegue a otros servidores",
|
||||||
"federation.federated.short": "Federado",
|
"federation.federated.short": "Federado",
|
||||||
"federation.local_only.long": "Evitar que esta publicación llegue a otros servidores",
|
"federation.local_only.long": "Evitar que esta publicación llegue a otros servidores",
|
||||||
|
|||||||
@@ -31,13 +31,8 @@
|
|||||||
"confirmations.missing_media_description.confirm": "Enviar de todos modos",
|
"confirmations.missing_media_description.confirm": "Enviar de todos modos",
|
||||||
"confirmations.missing_media_description.edit": "Editar medios",
|
"confirmations.missing_media_description.edit": "Editar medios",
|
||||||
"confirmations.missing_media_description.message": "Al menos a un adjunto le falta una descripción. Considera describir toda la multimedia para los débiles visuales antes de mandar el toot.",
|
"confirmations.missing_media_description.message": "Al menos a un adjunto le falta una descripción. Considera describir toda la multimedia para los débiles visuales antes de mandar el toot.",
|
||||||
"confirmations.unfilter.author": "Autor",
|
|
||||||
"confirmations.unfilter.confirm": "Mostrar",
|
|
||||||
"confirmations.unfilter.edit_filter": "Editar filtro",
|
|
||||||
"confirmations.unfilter.filters": "Coincidiendo {count, plural, one {filtro} other {filtros}}",
|
|
||||||
"direct.group_by_conversations": "Agrupar por conversación",
|
"direct.group_by_conversations": "Agrupar por conversación",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Cuentas destacadas",
|
"endorsed_accounts_editor.endorsed_accounts": "Cuentas destacadas",
|
||||||
"favourite_modal.combo": "Puedes presionar {combo} para omitir esto la próxima vez",
|
|
||||||
"federation.federated.long": "Permitir que esta publicación llegue a otros servidores",
|
"federation.federated.long": "Permitir que esta publicación llegue a otros servidores",
|
||||||
"federation.federated.short": "Federado",
|
"federation.federated.short": "Federado",
|
||||||
"federation.local_only.long": "Evitar que esta publicación llegue a otros servidores",
|
"federation.local_only.long": "Evitar que esta publicación llegue a otros servidores",
|
||||||
|
|||||||
@@ -7,9 +7,6 @@
|
|||||||
"compose.content-type.markdown": "مارکدون",
|
"compose.content-type.markdown": "مارکدون",
|
||||||
"compose.content-type.plain": "متن ساده",
|
"compose.content-type.plain": "متن ساده",
|
||||||
"confirmations.missing_media_description.edit": "ویرایش رسانه",
|
"confirmations.missing_media_description.edit": "ویرایش رسانه",
|
||||||
"confirmations.unfilter.author": "نویسنده",
|
|
||||||
"confirmations.unfilter.confirm": "نمایش",
|
|
||||||
"confirmations.unfilter.edit_filter": "ویرایش پالایه",
|
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "حسابهای پیشنهاد شده",
|
"endorsed_accounts_editor.endorsed_accounts": "حسابهای پیشنهاد شده",
|
||||||
"home.column_settings.advanced": "پیشرفته",
|
"home.column_settings.advanced": "پیشرفته",
|
||||||
"navigation_bar.app_settings": "تنظیمات کاره",
|
"navigation_bar.app_settings": "تنظیمات کاره",
|
||||||
|
|||||||
@@ -31,13 +31,8 @@
|
|||||||
"confirmations.missing_media_description.confirm": "Envoyer quand même",
|
"confirmations.missing_media_description.confirm": "Envoyer quand même",
|
||||||
"confirmations.missing_media_description.edit": "Modifier le média",
|
"confirmations.missing_media_description.edit": "Modifier le média",
|
||||||
"confirmations.missing_media_description.message": "Au moins un média joint manque d'une description. Pensez à décrire tous les médias attachés pour les malvoyant·e·s avant de publier votre post.",
|
"confirmations.missing_media_description.message": "Au moins un média joint manque d'une description. Pensez à décrire tous les médias attachés pour les malvoyant·e·s avant de publier votre post.",
|
||||||
"confirmations.unfilter.author": "Auteur",
|
|
||||||
"confirmations.unfilter.confirm": "Afficher",
|
|
||||||
"confirmations.unfilter.edit_filter": "Modifier le filtre",
|
|
||||||
"confirmations.unfilter.filters": "Correspondance avec {count, plural, one {un filtre} other {plusieurs filtres}}",
|
|
||||||
"direct.group_by_conversations": "Grouper par conversation",
|
"direct.group_by_conversations": "Grouper par conversation",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Comptes mis en avant",
|
"endorsed_accounts_editor.endorsed_accounts": "Comptes mis en avant",
|
||||||
"favourite_modal.combo": "Vous pouvez appuyer sur {combo} pour passer ceci la prochaine fois",
|
|
||||||
"federation.federated.long": "Permettre à ce post d’atteindre d'autres serveurs",
|
"federation.federated.long": "Permettre à ce post d’atteindre d'autres serveurs",
|
||||||
"federation.federated.short": "Fédéré",
|
"federation.federated.short": "Fédéré",
|
||||||
"federation.local_only.long": "Empêcher ce post d’atteindre d'autres serveurs",
|
"federation.local_only.long": "Empêcher ce post d’atteindre d'autres serveurs",
|
||||||
|
|||||||
@@ -31,13 +31,8 @@
|
|||||||
"confirmations.missing_media_description.confirm": "Envoyer quand même",
|
"confirmations.missing_media_description.confirm": "Envoyer quand même",
|
||||||
"confirmations.missing_media_description.edit": "Modifier le média",
|
"confirmations.missing_media_description.edit": "Modifier le média",
|
||||||
"confirmations.missing_media_description.message": "Au moins un média joint manque d'une description. Pensez à décrire tous les médias attachés pour les malvoyant·e·s avant de publier votre post.",
|
"confirmations.missing_media_description.message": "Au moins un média joint manque d'une description. Pensez à décrire tous les médias attachés pour les malvoyant·e·s avant de publier votre post.",
|
||||||
"confirmations.unfilter.author": "Auteur",
|
|
||||||
"confirmations.unfilter.confirm": "Afficher",
|
|
||||||
"confirmations.unfilter.edit_filter": "Modifier le filtre",
|
|
||||||
"confirmations.unfilter.filters": "Correspondance avec {count, plural, one {un filtre} other {plusieurs filtres}}",
|
|
||||||
"direct.group_by_conversations": "Grouper par conversation",
|
"direct.group_by_conversations": "Grouper par conversation",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Comptes mis en avant",
|
"endorsed_accounts_editor.endorsed_accounts": "Comptes mis en avant",
|
||||||
"favourite_modal.combo": "Vous pouvez appuyer sur {combo} pour passer ceci la prochaine fois",
|
|
||||||
"federation.federated.long": "Permettre à ce post d’atteindre d'autres serveurs",
|
"federation.federated.long": "Permettre à ce post d’atteindre d'autres serveurs",
|
||||||
"federation.federated.short": "Fédéré",
|
"federation.federated.short": "Fédéré",
|
||||||
"federation.local_only.long": "Empêcher ce post d’atteindre d'autres serveurs",
|
"federation.local_only.long": "Empêcher ce post d’atteindre d'autres serveurs",
|
||||||
|
|||||||
@@ -27,13 +27,8 @@
|
|||||||
"confirmations.missing_media_description.confirm": "Tetap kirim",
|
"confirmations.missing_media_description.confirm": "Tetap kirim",
|
||||||
"confirmations.missing_media_description.edit": "Sunting media",
|
"confirmations.missing_media_description.edit": "Sunting media",
|
||||||
"confirmations.missing_media_description.message": "Setidaknya satu lampiran media tidak memiliki deskripsi. Pertimbangkan untuk mendeskripsikan semua lampiran media untuk pengguna tunanetra sebelum mengirim toot Anda.",
|
"confirmations.missing_media_description.message": "Setidaknya satu lampiran media tidak memiliki deskripsi. Pertimbangkan untuk mendeskripsikan semua lampiran media untuk pengguna tunanetra sebelum mengirim toot Anda.",
|
||||||
"confirmations.unfilter.author": "Penulis",
|
|
||||||
"confirmations.unfilter.confirm": "Tampilkan",
|
|
||||||
"confirmations.unfilter.edit_filter": "Ubah saringan",
|
|
||||||
"confirmations.unfilter.filters": "Mencocokkan {count, plural, other {filter}}",
|
|
||||||
"direct.group_by_conversations": "Grupkan berdasarkan percakapan",
|
"direct.group_by_conversations": "Grupkan berdasarkan percakapan",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Akun pilihan",
|
"endorsed_accounts_editor.endorsed_accounts": "Akun pilihan",
|
||||||
"favourite_modal.combo": "Anda dapat menekan {combo} untuk melewati ini lain kali",
|
|
||||||
"federation.federated.long": "Izinkan postingan ini menjangkau server lain",
|
"federation.federated.long": "Izinkan postingan ini menjangkau server lain",
|
||||||
"federation.federated.short": "Difederasi",
|
"federation.federated.short": "Difederasi",
|
||||||
"federation.local_only.long": "Cegah postingan ini menjangkau server lain",
|
"federation.local_only.long": "Cegah postingan ini menjangkau server lain",
|
||||||
|
|||||||
@@ -26,13 +26,8 @@
|
|||||||
"confirmations.missing_media_description.confirm": "このまま投稿",
|
"confirmations.missing_media_description.confirm": "このまま投稿",
|
||||||
"confirmations.missing_media_description.edit": "メディアを編集",
|
"confirmations.missing_media_description.edit": "メディアを編集",
|
||||||
"confirmations.missing_media_description.message": "少なくとも1つの画像に視覚障害者のための画像説明が付与されていません。すべての画像に対して説明を付与することを望みます。",
|
"confirmations.missing_media_description.message": "少なくとも1つの画像に視覚障害者のための画像説明が付与されていません。すべての画像に対して説明を付与することを望みます。",
|
||||||
"confirmations.unfilter.author": "筆者",
|
|
||||||
"confirmations.unfilter.confirm": "見る",
|
|
||||||
"confirmations.unfilter.edit_filter": "フィルターを編集",
|
|
||||||
"confirmations.unfilter.filters": "適用されたフィルター",
|
|
||||||
"direct.group_by_conversations": "会話でグループ化",
|
"direct.group_by_conversations": "会話でグループ化",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "紹介しているユーザー",
|
"endorsed_accounts_editor.endorsed_accounts": "紹介しているユーザー",
|
||||||
"favourite_modal.combo": "次からは {combo} を押せば、これをスキップできます。",
|
|
||||||
"federation.federated.short": "連合",
|
"federation.federated.short": "連合",
|
||||||
"federation.local_only.short": "ローカル限定",
|
"federation.local_only.short": "ローカル限定",
|
||||||
"home.column_settings.advanced": "高度",
|
"home.column_settings.advanced": "高度",
|
||||||
|
|||||||
@@ -34,13 +34,9 @@
|
|||||||
"confirmations.missing_media_description.confirm": "그냥 보내기",
|
"confirmations.missing_media_description.confirm": "그냥 보내기",
|
||||||
"confirmations.missing_media_description.edit": "미디어 편집",
|
"confirmations.missing_media_description.edit": "미디어 편집",
|
||||||
"confirmations.missing_media_description.message": "하나 이상의 미디어에 대해 설명을 작성하지 않았습니다. 시각장애인을 위해 모든 미디어에 설명을 추가하는 것을 고려해주세요.",
|
"confirmations.missing_media_description.message": "하나 이상의 미디어에 대해 설명을 작성하지 않았습니다. 시각장애인을 위해 모든 미디어에 설명을 추가하는 것을 고려해주세요.",
|
||||||
"confirmations.unfilter.author": "작성자",
|
|
||||||
"confirmations.unfilter.confirm": "보기",
|
|
||||||
"confirmations.unfilter.edit_filter": "필터 편집",
|
|
||||||
"confirmations.unfilter.filters": "적용된 {count, plural, one {필터} other {필터들}}",
|
|
||||||
"direct.group_by_conversations": "대화별로 묶기",
|
"direct.group_by_conversations": "대화별로 묶기",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "추천하는 계정들",
|
"endorsed_accounts_editor.endorsed_accounts": "추천하는 계정들",
|
||||||
"favourite_modal.combo": "다음엔 {combo}를 눌러 건너뛸 수 있습니다",
|
"favourite_modal.favourite": "관심글로 지정할까요?",
|
||||||
"federation.federated.long": "이 게시물이 다른 서버에 전달되는 것을 허용",
|
"federation.federated.long": "이 게시물이 다른 서버에 전달되는 것을 허용",
|
||||||
"federation.federated.short": "연합됨",
|
"federation.federated.short": "연합됨",
|
||||||
"federation.local_only.long": "이 게시물이 다른 서버에 전달되는 것을 막기",
|
"federation.local_only.long": "이 게시물이 다른 서버에 전달되는 것을 막기",
|
||||||
@@ -148,12 +144,17 @@
|
|||||||
"settings.wide_view": "넓은 뷰 (데스크탑 모드 전용)",
|
"settings.wide_view": "넓은 뷰 (데스크탑 모드 전용)",
|
||||||
"settings.wide_view_hint": "컬럼들을 늘려서 활용 가능한 공간을 사용합니다.",
|
"settings.wide_view_hint": "컬럼들을 늘려서 활용 가능한 공간을 사용합니다.",
|
||||||
"status.collapse": "접기",
|
"status.collapse": "접기",
|
||||||
|
"status.filtered": "걸러짐",
|
||||||
"status.has_audio": "소리 파일이 첨부되어 있습니다",
|
"status.has_audio": "소리 파일이 첨부되어 있습니다",
|
||||||
"status.has_pictures": "그림 파일이 첨부되어 있습니다",
|
"status.has_pictures": "그림 파일이 첨부되어 있습니다",
|
||||||
"status.has_preview_card": "미리보기 카드가 첨부되어 있습니다",
|
"status.has_preview_card": "미리보기 카드가 첨부되어 있습니다",
|
||||||
"status.has_video": "영상이 첨부되어 있습니다",
|
"status.has_video": "영상이 첨부되어 있습니다",
|
||||||
|
"status.hide": "게시물 숨기기",
|
||||||
"status.in_reply_to": "이 글은 답글입니다",
|
"status.in_reply_to": "이 글은 답글입니다",
|
||||||
"status.is_poll": "이 글은 설문입니다",
|
"status.is_poll": "이 글은 설문입니다",
|
||||||
"status.local_only": "당신의 서버에서만 보입니다",
|
"status.local_only": "당신의 서버에서만 보입니다",
|
||||||
|
"status.show_filter_reason": "그냥 표시하기",
|
||||||
|
"status.show_less": "접기",
|
||||||
|
"status.show_more": "더보기",
|
||||||
"status.uncollapse": "펼치기"
|
"status.uncollapse": "펼치기"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,6 @@
|
|||||||
"confirmations.missing_media_description.confirm": "Toch verzenden",
|
"confirmations.missing_media_description.confirm": "Toch verzenden",
|
||||||
"confirmations.missing_media_description.edit": "Media bewerken",
|
"confirmations.missing_media_description.edit": "Media bewerken",
|
||||||
"confirmations.missing_media_description.message": "Minstens één media-bijlage mist een beschrijving. Overweeg om alle mediabijlagen voor slechtzienden te beschrijven voordat u uw toot verstuurt.",
|
"confirmations.missing_media_description.message": "Minstens één media-bijlage mist een beschrijving. Overweeg om alle mediabijlagen voor slechtzienden te beschrijven voordat u uw toot verstuurt.",
|
||||||
"confirmations.unfilter.author": "Auteur",
|
|
||||||
"confirmations.unfilter.confirm": "Weergeven",
|
|
||||||
"confirmations.unfilter.edit_filter": "Filter bewerken",
|
|
||||||
"direct.group_by_conversations": "Groeperen op gesprek",
|
"direct.group_by_conversations": "Groeperen op gesprek",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Aanbevolen accounts",
|
"endorsed_accounts_editor.endorsed_accounts": "Aanbevolen accounts",
|
||||||
"home.column_settings.advanced": "Geavanceerd",
|
"home.column_settings.advanced": "Geavanceerd",
|
||||||
|
|||||||
@@ -22,12 +22,8 @@
|
|||||||
"confirmations.missing_media_description.confirm": "Zignoruj i wyślij",
|
"confirmations.missing_media_description.confirm": "Zignoruj i wyślij",
|
||||||
"confirmations.missing_media_description.edit": "Edytuj załącznik multimedialny",
|
"confirmations.missing_media_description.edit": "Edytuj załącznik multimedialny",
|
||||||
"confirmations.missing_media_description.message": "Co najmniej jednemu załącznikowi multimedialnemu brakuje opisu. Z uwagi na osoby z zaburzeniami widzenia rozważ opisanie wszystkich załączników przed opublikowaniem wpisu.",
|
"confirmations.missing_media_description.message": "Co najmniej jednemu załącznikowi multimedialnemu brakuje opisu. Z uwagi na osoby z zaburzeniami widzenia rozważ opisanie wszystkich załączników przed opublikowaniem wpisu.",
|
||||||
"confirmations.unfilter.author": "Autor",
|
|
||||||
"confirmations.unfilter.confirm": "Pokaż",
|
|
||||||
"confirmations.unfilter.edit_filter": "Edytuj filtr",
|
|
||||||
"direct.group_by_conversations": "Grupuj rozmowami",
|
"direct.group_by_conversations": "Grupuj rozmowami",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Wybrane konta",
|
"endorsed_accounts_editor.endorsed_accounts": "Wybrane konta",
|
||||||
"favourite_modal.combo": "Możesz nacisnąć {combo}, aby pominąć to następnym razem",
|
|
||||||
"home.column_settings.advanced": "Zaawansowane",
|
"home.column_settings.advanced": "Zaawansowane",
|
||||||
"home.column_settings.filter_regex": "Filtruj, używając wyrażeń regularnych",
|
"home.column_settings.filter_regex": "Filtruj, używając wyrażeń regularnych",
|
||||||
"home.column_settings.show_direct": "Pokaż wiadomości bezpośrednie",
|
"home.column_settings.show_direct": "Pokaż wiadomości bezpośrednie",
|
||||||
|
|||||||
@@ -22,13 +22,8 @@
|
|||||||
"confirmations.missing_media_description.confirm": "Enviar mesmo assim",
|
"confirmations.missing_media_description.confirm": "Enviar mesmo assim",
|
||||||
"confirmations.missing_media_description.edit": "Editar mídia",
|
"confirmations.missing_media_description.edit": "Editar mídia",
|
||||||
"confirmations.missing_media_description.message": "Pelo menos um anexo de mídia não tem uma descrição. Considere descrever todos os anexos de mídia para deficientes visuais antes de enviar seu toot.",
|
"confirmations.missing_media_description.message": "Pelo menos um anexo de mídia não tem uma descrição. Considere descrever todos os anexos de mídia para deficientes visuais antes de enviar seu toot.",
|
||||||
"confirmations.unfilter.author": "Autor",
|
|
||||||
"confirmations.unfilter.confirm": "Exibir",
|
|
||||||
"confirmations.unfilter.edit_filter": "Editar filtro",
|
|
||||||
"confirmations.unfilter.filters": "Correspondência de {count, plural, one {filtro} other {filtros}}",
|
|
||||||
"direct.group_by_conversations": "Agrupar por conversa",
|
"direct.group_by_conversations": "Agrupar por conversa",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Contas em destaque",
|
"endorsed_accounts_editor.endorsed_accounts": "Contas em destaque",
|
||||||
"favourite_modal.combo": "Você pode pressionar {combo} para pular isso da próxima vez",
|
|
||||||
"home.column_settings.advanced": "Avançado",
|
"home.column_settings.advanced": "Avançado",
|
||||||
"home.column_settings.filter_regex": "Filtrar com uma expressão regular",
|
"home.column_settings.filter_regex": "Filtrar com uma expressão regular",
|
||||||
"home.column_settings.show_direct": "Mostrar DMs",
|
"home.column_settings.show_direct": "Mostrar DMs",
|
||||||
|
|||||||
@@ -20,13 +20,8 @@
|
|||||||
"confirmations.missing_media_description.confirm": "Lägg ut ändå",
|
"confirmations.missing_media_description.confirm": "Lägg ut ändå",
|
||||||
"confirmations.missing_media_description.edit": "Redigera media",
|
"confirmations.missing_media_description.edit": "Redigera media",
|
||||||
"confirmations.missing_media_description.message": "Minst en mediebilaga saknar beskrivning. Överväg att beskriva all media för synskadade innan du skickar din toot.",
|
"confirmations.missing_media_description.message": "Minst en mediebilaga saknar beskrivning. Överväg att beskriva all media för synskadade innan du skickar din toot.",
|
||||||
"confirmations.unfilter.author": "Användare",
|
|
||||||
"confirmations.unfilter.confirm": "Visa",
|
|
||||||
"confirmations.unfilter.edit_filter": "Redigera filter",
|
|
||||||
"confirmations.unfilter.filters": "Matchande {count, plural, one {filter} other {filters}}",
|
|
||||||
"direct.group_by_conversations": "Sortera efter konversation",
|
"direct.group_by_conversations": "Sortera efter konversation",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Utvalda konton",
|
"endorsed_accounts_editor.endorsed_accounts": "Utvalda konton",
|
||||||
"favourite_modal.combo": "Du kan trycka på {combo} för att skippa detta nästa gång",
|
|
||||||
"firehose.column_settings.allow_local_only": "Visa endast lokala inlägg i \"Alla\"",
|
"firehose.column_settings.allow_local_only": "Visa endast lokala inlägg i \"Alla\"",
|
||||||
"home.column_settings.advanced": "Avancerat",
|
"home.column_settings.advanced": "Avancerat",
|
||||||
"home.column_settings.filter_regex": "Filtrera bort med reguljära uttryck",
|
"home.column_settings.filter_regex": "Filtrera bort med reguljära uttryck",
|
||||||
|
|||||||
@@ -21,13 +21,8 @@
|
|||||||
"confirmations.missing_media_description.confirm": "Yine de gönder",
|
"confirmations.missing_media_description.confirm": "Yine de gönder",
|
||||||
"confirmations.missing_media_description.edit": "Medyayı düzenle",
|
"confirmations.missing_media_description.edit": "Medyayı düzenle",
|
||||||
"confirmations.missing_media_description.message": "En az bir medya eki açıklaması eksik. Gönderinizi göndermeden önce görme engelliler için tüm medya eklerini açıklamayı ön görün.",
|
"confirmations.missing_media_description.message": "En az bir medya eki açıklaması eksik. Gönderinizi göndermeden önce görme engelliler için tüm medya eklerini açıklamayı ön görün.",
|
||||||
"confirmations.unfilter.author": "Yazar",
|
|
||||||
"confirmations.unfilter.confirm": "Göster",
|
|
||||||
"confirmations.unfilter.edit_filter": "Filtreyi düzenle",
|
|
||||||
"confirmations.unfilter.filters": "Eşleşen {count, plural, one {filter} other {filters}}",
|
|
||||||
"direct.group_by_conversations": "Grup sohbeti",
|
"direct.group_by_conversations": "Grup sohbeti",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Öne çıkan hesaplar",
|
"endorsed_accounts_editor.endorsed_accounts": "Öne çıkan hesaplar",
|
||||||
"favourite_modal.combo": "Bir sonraki sefer {combo} tuşuna basabilirsiniz",
|
|
||||||
"settings.always_show_spoilers_field": "Her zaman İçerik Uyarısı alanını etkinleştir",
|
"settings.always_show_spoilers_field": "Her zaman İçerik Uyarısı alanını etkinleştir",
|
||||||
"settings.auto_collapse": "Otomatik küçülme",
|
"settings.auto_collapse": "Otomatik küçülme",
|
||||||
"settings.auto_collapse_all": "Her şey",
|
"settings.auto_collapse_all": "Her şey",
|
||||||
|
|||||||
@@ -22,13 +22,8 @@
|
|||||||
"confirmations.missing_media_description.confirm": "Все одно надіслати",
|
"confirmations.missing_media_description.confirm": "Все одно надіслати",
|
||||||
"confirmations.missing_media_description.edit": "Редагувати медіа",
|
"confirmations.missing_media_description.edit": "Редагувати медіа",
|
||||||
"confirmations.missing_media_description.message": "Принаймні одна медіа-прикріплення не має опису. Подумайте про описання всіх медіавкладень для людей з порушеннями зору перед відправкою дмуху.",
|
"confirmations.missing_media_description.message": "Принаймні одна медіа-прикріплення не має опису. Подумайте про описання всіх медіавкладень для людей з порушеннями зору перед відправкою дмуху.",
|
||||||
"confirmations.unfilter.author": "Автор",
|
|
||||||
"confirmations.unfilter.confirm": "Показати",
|
|
||||||
"confirmations.unfilter.edit_filter": "Редагувати фільтр",
|
|
||||||
"confirmations.unfilter.filters": "Відповідність {count, plural, one {filter} other {filters}}",
|
|
||||||
"direct.group_by_conversations": "Групування за розмовами",
|
"direct.group_by_conversations": "Групування за розмовами",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Рекомендовані облікові записи",
|
"endorsed_accounts_editor.endorsed_accounts": "Рекомендовані облікові записи",
|
||||||
"favourite_modal.combo": "Ви можете натиснути {combo}, щоб пропустити це наступного разу",
|
|
||||||
"firehose.column_settings.allow_local_only": "Відображати локальні повідомлення в \"Все\"",
|
"firehose.column_settings.allow_local_only": "Відображати локальні повідомлення в \"Все\"",
|
||||||
"home.column_settings.advanced": "Додатково",
|
"home.column_settings.advanced": "Додатково",
|
||||||
"home.column_settings.filter_regex": "Відфільтрувати за допомогою регулярних виразів",
|
"home.column_settings.filter_regex": "Відфільтрувати за допомогою регулярних виразів",
|
||||||
|
|||||||
@@ -34,13 +34,8 @@
|
|||||||
"confirmations.missing_media_description.confirm": "仍然发送",
|
"confirmations.missing_media_description.confirm": "仍然发送",
|
||||||
"confirmations.missing_media_description.edit": "编辑媒体",
|
"confirmations.missing_media_description.edit": "编辑媒体",
|
||||||
"confirmations.missing_media_description.message": "你没有为一个或多个媒体撰写描述。请考虑为视障人士添加描述。",
|
"confirmations.missing_media_description.message": "你没有为一个或多个媒体撰写描述。请考虑为视障人士添加描述。",
|
||||||
"confirmations.unfilter.author": "作者",
|
|
||||||
"confirmations.unfilter.confirm": "显示",
|
|
||||||
"confirmations.unfilter.edit_filter": "编辑筛选器",
|
|
||||||
"confirmations.unfilter.filters": "应用{count, plural, other {筛选器}}",
|
|
||||||
"direct.group_by_conversations": "按对话分组",
|
"direct.group_by_conversations": "按对话分组",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "精选账户",
|
"endorsed_accounts_editor.endorsed_accounts": "精选账户",
|
||||||
"favourite_modal.combo": "下次你可以按 {combo} 跳过这个",
|
|
||||||
"federation.federated.long": "允许此嘟文到达其它服务器",
|
"federation.federated.long": "允许此嘟文到达其它服务器",
|
||||||
"federation.federated.short": "联动",
|
"federation.federated.short": "联动",
|
||||||
"federation.local_only.long": "阻止此嘟文到达其它服务器",
|
"federation.local_only.long": "阻止此嘟文到达其它服务器",
|
||||||
@@ -148,12 +143,17 @@
|
|||||||
"settings.wide_view": "宽视图(仅限于桌面模式)",
|
"settings.wide_view": "宽视图(仅限于桌面模式)",
|
||||||
"settings.wide_view_hint": "拉伸列宽以更好地填充可用空间。",
|
"settings.wide_view_hint": "拉伸列宽以更好地填充可用空间。",
|
||||||
"status.collapse": "折叠",
|
"status.collapse": "折叠",
|
||||||
|
"status.filtered": "已过滤",
|
||||||
"status.has_audio": "附带音频",
|
"status.has_audio": "附带音频",
|
||||||
"status.has_pictures": "附带图片",
|
"status.has_pictures": "附带图片",
|
||||||
"status.has_preview_card": "附带预览卡片",
|
"status.has_preview_card": "附带预览卡片",
|
||||||
"status.has_video": "附带视频",
|
"status.has_video": "附带视频",
|
||||||
|
"status.hide": "隐藏嘟文",
|
||||||
"status.in_reply_to": "此嘟文是回复",
|
"status.in_reply_to": "此嘟文是回复",
|
||||||
"status.is_poll": "此嘟文是投票",
|
"status.is_poll": "此嘟文是投票",
|
||||||
"status.local_only": "此嘟文仅本站可见",
|
"status.local_only": "此嘟文仅本站可见",
|
||||||
|
"status.show_filter_reason": "仍然显示",
|
||||||
|
"status.show_less": "部分显示",
|
||||||
|
"status.show_more": "完全显示",
|
||||||
"status.uncollapse": "展开"
|
"status.uncollapse": "展开"
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user