mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-13 07:49:29 +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
|
||||
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:v1.6.0
|
||||
image: libretranslate/libretranslate:v1.6.1
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- 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
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7.0.1
|
||||
uses: peter-evans/create-pull-request@v7.0.5
|
||||
with:
|
||||
commit-message: 'New Crowdin translations'
|
||||
title: 'New Crowdin Translations (automated)'
|
||||
|
||||
3
.github/workflows/crowdin-upload.yml
vendored
3
.github/workflows/crowdin-upload.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: Crowdin / Upload translations
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
@@ -32,7 +31,7 @@ jobs:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
crowdin_branch_name: main
|
||||
crowdin_branch_name: ${{ github.base_ref || github.ref_name }}
|
||||
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
||||
|
||||
62
CHANGELOG.md
62
CHANGELOG.md
@@ -2,7 +2,7 @@
|
||||
|
||||
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.
|
||||
|
||||
@@ -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)\
|
||||
This fixes a longstanding open redirect in Mastodon, at the cost of added friction when local links to remote resources are shared.
|
||||
- Change `form-action` Content-Security-Policy directive to be more restrictive (#26897 by @ClearlyClaire)
|
||||
- Fix ReDoS vulnerability on some Ruby versions ([GHSA-jpxp-r43f-rhvx](https://github.com/mastodon/mastodon/security/advisories/GHSA-jpxp-r43f-rhvx))
|
||||
- Change `form-action` Content-Security-Policy directive to be more restrictive (#26897 and #32241 by @ClearlyClaire)
|
||||
- Update dependencies
|
||||
|
||||
### Added
|
||||
|
||||
- **Add server-side notification grouping** (#29889, #30576, #30685, #30688, #30707, #30776, #30779, #30781, #30440, #31062, #31098, #31076, #31111, #31123, #31223, #31214, #31224, #31299, #31325, #31347, #31304, #31326, #31384, #31403, #31433, #31509, #31486, #31513, #31592, #31594, #31638, #31746, #31652, #31709, #31725, #31745, #31613, #31657, #31840, #31610 and #31929 by @ClearlyClaire, @Gargron, @mgmn, and @renchap)\
|
||||
- **Add server-side notification grouping** (#29889, #30576, #30685, #30688, #30707, #30776, #30779, #30781, #30440, #31062, #31098, #31076, #31111, #31123, #31223, #31214, #31224, #31299, #31325, #31347, #31304, #31326, #31384, #31403, #31433, #31509, #31486, #31513, #31592, #31594, #31638, #31746, #31652, #31709, #31725, #31745, #31613, #31657, #31840, #31610, #31929, #32089, #32085, #32243, #32179 and #32254 by @ClearlyClaire, @Gargron, @mgmn, and @renchap)\
|
||||
Group notifications of the same type for the same target, so that your notifications no longer get cluttered by boost and favorite notifications as soon as a couple of your posts get traction.\
|
||||
This is done server-side so that clients can efficiently get relevant groups without having to go through numerous pages of individual notifications.\
|
||||
As part of this, the visual design of the entire notifications feature has been revamped.\
|
||||
@@ -27,7 +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
|
||||
- `POST /api/v2/notifications/:group_key/dimsiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group
|
||||
- `GET /api/v2/notifications/:unread_count`: https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count
|
||||
- **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, and #31723 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\
|
||||
- **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, #31723, #32062 and #32281 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\
|
||||
The old “Block notifications from non-followers”, “Block notifications from people you don't follow” and “Block direct messages from people you don't follow” notification settings have been replaced by a new set of settings found directly in the notification column.\
|
||||
You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\
|
||||
Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\
|
||||
@@ -60,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)\
|
||||
You can now see public posts mentioning currently-trending articles from people who have opted into discovery features.\
|
||||
This adds a new REST API endpoint: https://docs.joinmastodon.org/methods/timelines/#link
|
||||
- **Add author highlight for news articles whose authors are on the fediverse** (#30398, #30670, #30521, #30846, #31819, and #31900 by @Gargron and @oneiros)\
|
||||
- **Add author highlight for news articles whose authors are on the fediverse** (#30398, #30670, #30521, #30846, #31819, #31900 and #32188 by @Gargron, @mjankowski and @oneiros)\
|
||||
This adds a mechanism to [highlight the author of news articles](https://blog.joinmastodon.org/2024/07/highlighting-journalism-on-mastodon/) shared on Mastodon.\
|
||||
Articles hosted outside the fediverse can indicate a fediverse author with a meta tag:
|
||||
```html
|
||||
@@ -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.
|
||||
- **Add support for Redis sentinel** (#31694, #31623, #31744, #31767, and #31768 by @ThisIsMissEm and @oneiros)\
|
||||
See https://docs.joinmastodon.org/admin/scaling/#redis-sentinel
|
||||
- Add ability to reorder uploaded media before posting in web UI (#28456 by @Gargron)
|
||||
- **Add ability to reorder uploaded media before posting in web UI** (#28456 and #32093 by @Gargron)
|
||||
- Add “A Mastodon update is available.” message on admin dashboard for non-bugfix updates (#32106 by @ClearlyClaire)
|
||||
- Add ability to view alt text by clicking the ALT badge in web UI (#32058 by @Gargron)
|
||||
- Add preview of followers removed in domain block modal in web UI (#32032 and #32105 by @ClearlyClaire and @Gargron)
|
||||
- Add reblogs and favourites counts to statuses in ActivityPub (#32007 by @Gargron)
|
||||
- Add moderation interface for searching hashtags (#30880 by @ThisIsMissEm)
|
||||
- Add ability for admins to configure instance favicon and logo (#30040, #30208, #30259, #30375, #30734, #31016, and #30205 by @ClearlyClaire, @FawazFarid, @JasonPunyon, @mgmn, and @renchap)\
|
||||
This is also exposed through the REST API: https://docs.joinmastodon.org/entities/Instance/#icon
|
||||
@@ -122,14 +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 Kashubian, Pennsylvania Dutch, Vai, Jawi Malay, Mohawk and Low German to posting languages (#26024, #26634, #27136, #29098, #27115, and #27434 by @EngineerDali, @HelgeKrueger, and @gunchleoc)
|
||||
- Add option to use native Ruby driver for Redis through `REDIS_DRIVER=ruby` (#30717 by @vmstan)
|
||||
- Add support for libvips in addition to ImageMagick (#30090, #30590, #30597, #30632, #30857, #30869, and #30858 by @ClearlyClaire, @Gargron, and @mjankowski)\
|
||||
- Add support for libvips in addition to ImageMagick (#30090, #30590, #30597, #30632, #30857, #30869, #30858 and #32104 by @ClearlyClaire, @Gargron, and @mjankowski)\
|
||||
Server admins can now use libvips as a faster and lighter alternative to ImageMagick for processing user-uploaded images.\
|
||||
This requires libvips 8.13 or newer, and needs to be enabled with `MASTODON_USE_LIBVIPS=true`.\
|
||||
This is enabled by default in the official Docker images, and is intended to completely replace ImageMagick in the future.
|
||||
- Add validations to `Web::PushSubscription` (#30540 and #30542 by @ThisIsMissEm)
|
||||
- Add anchors to each authorized application in `/oauth/authorized_applications` (#31677 by @fowl2)
|
||||
- Add active animation to header settings button (#30221, #30307, and #30388 by @daudix)
|
||||
- Add OpenTelemetry instrumentation (#30130, #30322, #30353, and #30350 by @julianocosta89, @renchap, and @robbkidd)\
|
||||
- Add OpenTelemetry instrumentation (#30130, #30322, #30353, #30350 and #31998 by @julianocosta89, @renchap, @robbkidd and @timetinytim)\
|
||||
See https://docs.joinmastodon.org/admin/config/#otel for documentation
|
||||
- Add API to get multiple accounts and statuses (#27871 and #30465 by @ClearlyClaire)\
|
||||
This adds `GET /api/v1/accounts` and `GET /api/v1/statuses` to the REST API, see https://docs.joinmastodon.org/methods/accounts/#index and https://docs.joinmastodon.org/methods/statuses/#index
|
||||
@@ -138,7 +143,6 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- Add RFC8414 OAuth 2.0 server metadata (#29191 by @ThisIsMissEm)
|
||||
- Add loading indicator and empty result message to advanced interface search (#30085 by @ClearlyClaire)
|
||||
- Add `profile` OAuth 2.0 scope, allowing more limited access to user data (#29087 and #30357 by @ThisIsMissEm)
|
||||
- Add global Regexp timeout (#31928 by @ClearlyClaire)
|
||||
- Add the role ID to the badge component (#29707 by @renchap)
|
||||
- Add diagnostic message for failure during CLI search deploy (#29462 by @mjankowski)
|
||||
- Add pagination `Link` headers on API accounts/statuses when pinned true (#29442 by @mjankowski)
|
||||
@@ -146,10 +150,12 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- Add groundwork for annual reports for accounts (#28693 by @Gargron)\
|
||||
This lays the groundwork for a “year-in-review”/“wrapped” style report for local users, but is currently not in use.
|
||||
- Add notification email on invalid second authenticator (#28822 by @ClearlyClaire)
|
||||
- Add date of account deletion in list of accounts in the admin interface (#25640 by @tribela)
|
||||
- Add new emojis from `jdecked/twemoji` 15.0 (#28404 by @TheEssem)
|
||||
- Add configurable error handling in attachment batch deletion (#28184 by @vmstan)\
|
||||
This makes the S3 batch size configurable through the `S3_BATCH_DELETE_LIMIT` environment variable (defaults to 1000), and adds some retry logic, configurable through the `S3_BATCH_DELETE_RETRY` environment variable (defaults to 3).
|
||||
- Add VAPID public key to instance serializer (#28006 by @ThisIsMissEm)
|
||||
- Add support for serving JRD `/.well-known/host-meta.json` in addition to XRD host-meta (#32206 by @c960657)
|
||||
- Add `nodeName` and `nodeDescription` to nodeinfo `metadata` (#28079 by @6543)
|
||||
- Add Thai diacritics and tone marks in `HASHTAG_INVALID_CHARS_RE` (#26576 by @ppnplus)
|
||||
- Add variable delay before link verification of remote account links (#27774 by @ClearlyClaire)
|
||||
@@ -164,18 +170,18 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
|
||||
### Changed
|
||||
|
||||
- **Change icons throughout the web interface** (#27385, #27539, #27555, #27579, #27700, #27817, #28519, #28709, #28064, #28775, #28780, #27924, #29294, #29395, #29537, #29569, #29610, #29612, #29649, #29844, #27780, #30974, #30963, #30962, #30961, #31362, #31363, #31359, #31371, #31360, #31512, #31511, and #31525 by @ClearlyClaire, @Gargron, @arbolitoloco1, @mjankowski, @nclm, @renchap, @ronilaukkarinen, and @zunda)\
|
||||
- **Change icons throughout the web interface** (#27385, #27539, #27555, #27579, #27700, #27817, #28519, #28709, #28064, #28775, #28780, #27924, #29294, #29395, #29537, #29569, #29610, #29612, #29649, #29844, #27780, #30974, #30963, #30962, #30961, #31362, #31363, #31359, #31371, #31360, #31512, #31511, #31525, #32153, and #32201 by @ClearlyClaire, @Gargron, @arbolitoloco1, @mjankowski, @nclm, @renchap, @ronilaukkarinen, and @zunda)\
|
||||
This changes all the interface icons from FontAwesome to Material Symbols for a more modern look, consistent with the official Mastodon Android app.\
|
||||
In addition, better care is given to pixel alignment, and icon variants are used to better highlight active/inactive state.
|
||||
- **Change design of compose form in web UI** (#28119, #29059, #29248, #29372, #29384, #29417, #29456, #29406, #29651, #29659, and #31889 by @ClearlyClaire, @Gargron, @eai04191, @hinaloe, and @ronilaukkarinen)\
|
||||
- **Change design of compose form in web UI** (#28119, #29059, #29248, #29372, #29384, #29417, #29456, #29406, #29651, #29659, #31889 and #32033 by @ClearlyClaire, @Gargron, @eai04191, @hinaloe, and @ronilaukkarinen)\
|
||||
The compose form has been completely redesigned for a more modern and consistent look, as well as spelling out the chosen privacy setting and language name at all times.\
|
||||
As part of this, the “Unlisted” privacy setting has been renamed to “Quiet public”.
|
||||
- **Change design of modals in the web UI** (#29576, #29614, #29640, #29644, #30131, #30884, #31399, #31555, #31752, #31801, #31883, #31844, #31864, and #31943 by @ClearlyClaire, @Gargron, @tribela and @vmstan)\
|
||||
The mute, block, and domain block confirmation modals have been completely redesigned to be clearer and include more detailed information on the action to be performed.\
|
||||
They also have a more modern and consistent design, along with other confirmation modals in the application.
|
||||
- **Change colors throughout the web UI** (#29522, #29584, #29653, #29779, #29803, #29809, #29808, #29828, #31034, #31168, #31266, #31348, #31349, #31361, and #31510 by @ClearlyClaire, @Gargron, @renchap, and @vmstan)
|
||||
- **Change colors throughout the web UI** (#29522, #29584, #29653, #29779, #29803, #29809, #29808, #29828, #31034, #31168, #31266, #31348, #31349, #31361, #31510 and #32128 by @ClearlyClaire, @Gargron, @mjankowski, @renchap, and @vmstan)
|
||||
- **Change onboarding prompt to follow suggestions carousel in web UI** (#28878, #29272, and #31912 by @Gargron)
|
||||
- **Change email templates** (#28416, #28755, #28814, #29064, #28883, #29470, #29607, #29761, #29760, and #29879 by @ClearlyClaire, @Gargron, @hteumeuleu, and @mjankowski)\
|
||||
- **Change email templates** (#28416, #28755, #28814, #29064, #28883, #29470, #29607, #29761, #29760, #29879, #32073 and #32132 by @c960657, @ClearlyClaire, @Gargron, @hteumeuleu, and @mjankowski)\
|
||||
All emails to end-users have been completely redesigned with a fresh new look, providing more information while making them easier to read and keeping maximum compatibility across mail clients.
|
||||
- **Change follow recommendations algorithm** (#28314, #28433, #29017, #29108, #29306, #29550, #29619, and #31474 by @ClearlyClaire, @Gargron, @kernal053, @mjankowski, and @wheatear-dev)\
|
||||
This replaces the “past interactions” recommendation algorithm with a “friends of friends” algorithm that suggests accounts followed by people you follow, and a “similar profiles” algorithm that suggests accounts with a profile similar to your most recent follows.\
|
||||
@@ -188,10 +194,17 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
Administrators may need to update their setup accordingly.
|
||||
- Change how content warnings and filters are displayed in web UI (#31365, and #31761 by @Gargron)
|
||||
- Change preview card processing to ignore `undefined` as canonical url (#31882 by @oneiros)
|
||||
- Change embedded posts to use web UI (#31766 by @Gargron)
|
||||
- Change embedded posts to use web UI (#31766, #32135 and #32271 by @Gargron)
|
||||
- Change inner borders in media galleries in web UI (#31852 by @Gargron)
|
||||
- Change design of hide media button in web UI (#31807 by @Gargron)
|
||||
- Change design of media attachments and profile media tab in web UI (#31807, #32048, #31967, #32217, #32224 and #32257 by @ClearlyClaire and @Gargron)
|
||||
- Change labels on thread indicators in web UI (#31806 by @Gargron)
|
||||
- Change label of "Data export" menu item in settings interface (#32099 by @c960657)
|
||||
- Change responsive break points on navigation panel in web UI (#32034 by @Gargron)
|
||||
- Change cursor to `not-allowed` on disabled buttons (#32076 by @mjankowski)
|
||||
- Change OAuth authorization prompt to not refer to apps as “third-party” (#32005 by @Gargron)
|
||||
- Change Mastodon to issue correct HTTP signatures by default (#31994 by @ClearlyClaire)
|
||||
- Change zoom icon in web UI (#29683 by @Gargron)
|
||||
- Change directory page to use URL query strings for options (#31980, #31977 and #31984 by @ClearlyClaire and @renchap)
|
||||
- Change report action buttons to be disabled when action has already been taken (#31773, #31822, and #31899 by @ClearlyClaire and @ThisIsMissEm)
|
||||
- Change width of columns in advanced web UI (#31762 by @Gargron)
|
||||
- Change design of unread conversations in web UI (#31763 by @Gargron)
|
||||
@@ -254,6 +267,7 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove unused E2EE messaging code and related `crypto` OAuth scope (#31193, #31945, #31963, and #31964 by @ClearlyClaire and @mjankowski)
|
||||
- Remove StatsD integration (replaced by OpenTelemetry) (#30240 by @mjankowski)
|
||||
- Remove `CacheBuster` default options (#30718 by @mjankowski)
|
||||
- Remove home marker updates from the Web UI (#22721 by @davbeck)\
|
||||
@@ -269,9 +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 various issues when in link preview card generation (#28748, #30017, #30362, #30173, #30853, #30929, #30933, #30957, #30987, and #31144 by @adamniedzielski, @oneiros, @phocks, @timothyjrogers, and @tribela)
|
||||
- Fix handling of missing links in Webfinger responses (#31030 by @adamniedzielski)
|
||||
- Fix error when accepting an appeal for sensitive posts deleted in the meantime (#32037 by @ClearlyClaire)
|
||||
- Fix error when encountering reblog of deleted post in feed rebuild (#32001 by @ClearlyClaire)
|
||||
- Fix Safari browser glitch related to horizontal scrolling (#31960 by @Gargron)
|
||||
- Fix unresolvable mentions sometimes preventing processing incoming posts (#29215 by @tribela and @ClearlyClaire)
|
||||
- Fix too many requests caused by relationship look-ups in web UI (#32042 by @Gargron)
|
||||
- Fix links for reblogs in moderation interface (#31979 by @ClearlyClaire)
|
||||
- Fix the appearance of avatars when they do not load (#31966 and #32270 by @Gargron and @renchap)
|
||||
- Fix spurious error notifications for aborted requests in web UI (#31952 by @c960657)
|
||||
- Fix HTTP 500 error in `/api/v1/polls/:id/votes` when required `choices` parameter is missing (#25598 by @danielmbrasil)
|
||||
- Fix security context sometimes not being added in LD-Signed activities (#31871 by @ClearlyClaire)
|
||||
- Fix cross-origin loading of `inert.css` polyfill (#30687 by @louis77)
|
||||
- Fix wrapping in dashboard quick access buttons (#32043 by @renchap)
|
||||
- Fix recently used tags hint being displayed in profile edition page when there is none (#32120 by @mjankowski)
|
||||
- Fix checkbox lists on narrow screens in the settings interface (#32112 by @mjankowski)
|
||||
- Fix the position of status action buttons being affected by interaction counters (#32084 by @renchap)
|
||||
- Fix the summary of converted ActivityPub object types to be treated as HTML (#28629 by @Menrath)
|
||||
- Fix cutoff of instance name in sign-up form (#30598 by @oneiros)
|
||||
- Fix invalid date searches returning 503 errors (#31526 by @notchairmk)
|
||||
- Fix invalid `visibility` values in `POST /api/v1/statuses` returning 500 errors (#31571 by @c960657)
|
||||
@@ -285,10 +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 right-to-left text in preview cards (#30930 by @ClearlyClaire)
|
||||
- Fix rack attack `match_type` value typo in logging config (#30514 by @mjankowski)
|
||||
- Fix various cases of duplicate, missing, or inconsistent borders or scrollbar styles (#31068, #31286, #31268, #31275, #31284, #31305, #31346, #31372, #31373, #31389, #31432, #31391, and #31445 by @valtlai and @vmstan)
|
||||
- Fix various cases of duplicate, missing, or inconsistent borders or scrollbar styles (#31068, #31286, #31268, #31275, #31284, #31305, #31346, #31372, #31373, #31389, #31432, #31391, #31445, #32091, #32147 and #32137 by @ClearlyClaire, @mjankowski, @valtlai and @vmstan)
|
||||
- Fix editing description of media uploads with custom thumbnails (#32221 by @ClearlyClaire)
|
||||
- Fix race condition in `POST /api/v1/push/subscription` (#30166 by @ClearlyClaire)
|
||||
- Fix post deletion not being delayed when those are part of an account warning (#30163 by @ClearlyClaire)
|
||||
- Fix rendering error on `/start` when not logged in (#30023 by @timothyjrogers)
|
||||
- Fix unneeded requests to blocked domains when receiving relayed signed activities from them (#31161 by @ClearlyClaire)
|
||||
- Fix logo pushing header buttons out of view on certain conditions in mobile layout (#29787 by @ClearlyClaire)
|
||||
- Fix notification-related records not being reattributed when merging accounts (#29694 by @ClearlyClaire)
|
||||
- Fix results/query in `api/v1/featured_tags/suggestions` (#29597 by @mjankowski)
|
||||
@@ -298,6 +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 filters title and keywords overflow (#29396 by @GeopJr)
|
||||
- Fix incorrect date format in “Follows and followers” (#29390 by @JasonPunyon)
|
||||
- Fix navigation item active highlight for some paths (#32159 by @mjankowski)
|
||||
- Fix “Edit media” modal sizing and layout when space-constrained (#27095 by @ronilaukkarinen)
|
||||
- Fix modal container bounds (#29185 by @nico3333fr)
|
||||
- Fix inefficient HTTP signature parsing using regexps and `StringScanner` (#29133 by @ClearlyClaire)
|
||||
|
||||
1
Gemfile
1
Gemfile
@@ -47,7 +47,6 @@ gem 'color_diff', '~> 0.1'
|
||||
gem 'csv', '~> 3.2'
|
||||
gem 'discard', '~> 1.2'
|
||||
gem 'doorkeeper', '~> 5.6'
|
||||
gem 'ed25519', '~> 1.3'
|
||||
gem 'fast_blank', '~> 1.0'
|
||||
gem 'fastimage'
|
||||
gem 'hiredis', '~> 0.6'
|
||||
|
||||
50
Gemfile.lock
50
Gemfile.lock
@@ -100,20 +100,20 @@ GEM
|
||||
attr_required (1.0.2)
|
||||
awrence (1.2.1)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.974.0)
|
||||
aws-sdk-core (3.205.0)
|
||||
aws-partitions (1.978.0)
|
||||
aws-sdk-core (3.209.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.91.0)
|
||||
aws-sdk-core (~> 3, >= 3.205.0)
|
||||
aws-sdk-kms (1.94.0)
|
||||
aws-sdk-core (~> 3, >= 3.207.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.162.0)
|
||||
aws-sdk-core (~> 3, >= 3.205.0)
|
||||
aws-sdk-s3 (1.166.0)
|
||||
aws-sdk-core (~> 3, >= 3.207.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.9.1)
|
||||
aws-sigv4 (1.10.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-storage-blob (2.0.3)
|
||||
azure-storage-common (~> 2.0)
|
||||
@@ -134,7 +134,7 @@ GEM
|
||||
bindata (2.5.0)
|
||||
binding_of_caller (1.0.1)
|
||||
debug_inspector (>= 1.2.0)
|
||||
blurhash (0.1.7)
|
||||
blurhash (0.1.8)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.2.1)
|
||||
@@ -197,7 +197,7 @@ GEM
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (5.1.0)
|
||||
devise-two-factor (6.0.0)
|
||||
activesupport (~> 7.0)
|
||||
devise (~> 4.0)
|
||||
railties (~> 7.0)
|
||||
@@ -212,9 +212,8 @@ GEM
|
||||
domain_name (0.6.20240107)
|
||||
doorkeeper (5.7.1)
|
||||
railties (>= 5)
|
||||
dotenv (3.1.2)
|
||||
dotenv (3.1.4)
|
||||
drb (2.2.1)
|
||||
ed25519 (1.3.0)
|
||||
elasticsearch (7.17.11)
|
||||
elasticsearch-api (= 7.17.11)
|
||||
elasticsearch-transport (= 7.17.11)
|
||||
@@ -290,7 +289,7 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (3.25.4)
|
||||
google-protobuf (3.25.5)
|
||||
googleapis-common-protos-types (1.15.0)
|
||||
google-protobuf (>= 3.18, < 5.a)
|
||||
haml (6.3.0)
|
||||
@@ -348,7 +347,7 @@ GEM
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.7.2)
|
||||
irb (1.14.0)
|
||||
irb (1.14.1)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
@@ -407,7 +406,7 @@ GEM
|
||||
llhttp-ffi (0.5.0)
|
||||
ffi-compiler (~> 1.0)
|
||||
rake (~> 13.0)
|
||||
logger (1.6.0)
|
||||
logger (1.6.1)
|
||||
lograge (0.14.0)
|
||||
actionpack (>= 4)
|
||||
activesupport (>= 4)
|
||||
@@ -429,7 +428,7 @@ GEM
|
||||
addressable (~> 2.5)
|
||||
azure-storage-blob (~> 2.0.1)
|
||||
hashie (~> 5.0)
|
||||
memory_profiler (1.0.2)
|
||||
memory_profiler (1.1.0)
|
||||
mime-types (3.5.2)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2024.0820)
|
||||
@@ -602,7 +601,7 @@ GEM
|
||||
actionmailer (>= 3)
|
||||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
propshaft (1.0.0)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
@@ -610,7 +609,7 @@ GEM
|
||||
psych (5.1.2)
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.4.2)
|
||||
puma (6.4.3)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.4.0)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -699,7 +698,7 @@ GEM
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
rexml (3.3.7)
|
||||
rexml (3.3.8)
|
||||
rotp (6.3.0)
|
||||
rouge (4.3.0)
|
||||
rpam2 (4.0.2)
|
||||
@@ -749,15 +748,15 @@ GEM
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-capybara (2.21.0)
|
||||
rubocop (~> 1.41)
|
||||
rubocop-performance (1.21.1)
|
||||
rubocop-performance (1.22.1)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails (2.25.1)
|
||||
rubocop-rails (2.26.2)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
rubocop (>= 1.52.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rspec (3.0.4)
|
||||
rubocop-rspec (3.0.5)
|
||||
rubocop (~> 1.61)
|
||||
rubocop-rspec_rails (2.30.0)
|
||||
rubocop (~> 1.61)
|
||||
@@ -782,7 +781,7 @@ GEM
|
||||
scenic (1.8.0)
|
||||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
selenium-webdriver (4.24.0)
|
||||
selenium-webdriver (4.25.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
@@ -885,7 +884,7 @@ GEM
|
||||
webfinger (1.2.0)
|
||||
activesupport
|
||||
httpclient (>= 2.4)
|
||||
webmock (3.23.1)
|
||||
webmock (3.24.0)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
@@ -894,7 +893,7 @@ GEM
|
||||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 5.2)
|
||||
semantic_range (>= 2.3.0)
|
||||
webrick (1.8.1)
|
||||
webrick (1.8.2)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
@@ -937,7 +936,6 @@ DEPENDENCIES
|
||||
discard (~> 1.2)
|
||||
doorkeeper (~> 5.6)
|
||||
dotenv
|
||||
ed25519 (~> 1.3)
|
||||
email_spec
|
||||
fabrication (~> 2.30)
|
||||
faker (~> 3.2)
|
||||
|
||||
11
SECURITY.md
11
SECURITY.md
@@ -13,8 +13,9 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 4.2.x | Yes |
|
||||
| 4.1.x | Yes |
|
||||
| < 4.1 | No |
|
||||
| Version | Supported |
|
||||
| ------- | ---------------- |
|
||||
| 4.3.x | Yes |
|
||||
| 4.2.x | Yes |
|
||||
| 4.1.x | Until 2025-04-08 |
|
||||
| < 4.1 | No |
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::ClaimsController < ActivityPub::BaseController
|
||||
skip_before_action :authenticate_user!
|
||||
|
||||
before_action :require_account_signature!
|
||||
before_action :set_claim_result
|
||||
|
||||
def create
|
||||
render json: @claim_result, serializer: ActivityPub::OneTimeKeySerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_claim_result
|
||||
@claim_result = ::Keys::ClaimService.new.call(@account.id, params[:id])
|
||||
end
|
||||
end
|
||||
@@ -22,8 +22,6 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
@items = @items.map { |item| item.distributable? ? item : ActivityPub::TagManager.instance.uri_for(item) }
|
||||
when 'tags'
|
||||
@items = for_signed_account { @account.featured_tags }
|
||||
when 'devices'
|
||||
@items = @account.devices
|
||||
else
|
||||
not_found
|
||||
end
|
||||
@@ -31,7 +29,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
|
||||
def set_size
|
||||
case params[:id]
|
||||
when 'featured', 'devices', 'tags'
|
||||
when 'featured', 'tags'
|
||||
@size = @items.size
|
||||
else
|
||||
not_found
|
||||
@@ -42,7 +40,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
case params[:id]
|
||||
when 'featured'
|
||||
@type = :ordered
|
||||
when 'devices', 'tags'
|
||||
when 'tags'
|
||||
@type = :unordered
|
||||
else
|
||||
not_found
|
||||
|
||||
36
app/controllers/activitypub/likes_controller.rb
Normal file
36
app/controllers/activitypub/likes_controller.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::LikesController < ActivityPub::BaseController
|
||||
include Authorization
|
||||
|
||||
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||
|
||||
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||
before_action :set_status
|
||||
|
||||
def index
|
||||
expires_in 0, public: @status.distributable? && public_fetch_mode?
|
||||
render json: likes_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pundit_user
|
||||
signed_request_account
|
||||
end
|
||||
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
def likes_collection_presenter
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_status_likes_url(@account, @status),
|
||||
type: :unordered,
|
||||
size: @status.favourites_count
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -12,7 +12,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
||||
before_action :set_replies
|
||||
|
||||
def index
|
||||
expires_in 0, public: public_fetch_mode?
|
||||
expires_in 0, public: @status.distributable? && public_fetch_mode?
|
||||
render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true
|
||||
end
|
||||
|
||||
|
||||
36
app/controllers/activitypub/shares_controller.rb
Normal file
36
app/controllers/activitypub/shares_controller.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::SharesController < ActivityPub::BaseController
|
||||
include Authorization
|
||||
|
||||
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||
|
||||
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||
before_action :set_status
|
||||
|
||||
def index
|
||||
expires_in 0, public: @status.distributable? && public_fetch_mode?
|
||||
render json: shares_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pundit_user
|
||||
signed_request_account
|
||||
end
|
||||
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
def shares_collection_presenter
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_status_shares_url(@account, @status),
|
||||
type: :unordered,
|
||||
size: @status.reblogs_count
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -7,7 +7,7 @@ class Api::OEmbedController < Api::BaseController
|
||||
before_action :require_public_status!
|
||||
|
||||
def show
|
||||
render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
|
||||
render json: @status, serializer: OEmbedSerializer, width: params[:maxwidth], height: params[:maxheight]
|
||||
end
|
||||
|
||||
private
|
||||
@@ -23,12 +23,4 @@ class Api::OEmbedController < Api::BaseController
|
||||
def status_finder
|
||||
StatusFinder.new(params[:url])
|
||||
end
|
||||
|
||||
def maxwidth_or_default
|
||||
(params[:maxwidth].presence || 400).to_i
|
||||
end
|
||||
|
||||
def maxheight_or_default
|
||||
params[:maxheight].present? ? params[:maxheight].to_i : nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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_around_action :set_locale
|
||||
|
||||
LIMIT = 10
|
||||
|
||||
vary_by ''
|
||||
|
||||
def index
|
||||
@@ -35,10 +37,10 @@ class Api::V1::Peers::SearchController < Api::BaseController
|
||||
field: 'accounts_count',
|
||||
modifier: 'log2p',
|
||||
},
|
||||
}).limit(10).pluck(:domain)
|
||||
}).limit(LIMIT).pluck(:domain)
|
||||
else
|
||||
domain = normalized_domain
|
||||
@domains = Instance.searchable.domain_starts_with(domain).limit(10).pluck(:domain)
|
||||
@domains = Instance.searchable.domain_starts_with(domain).limit(LIMIT).pluck(:domain)
|
||||
end
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
@domains = []
|
||||
|
||||
@@ -9,7 +9,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController
|
||||
return not_found if @status.hidden?
|
||||
|
||||
if @status.local?
|
||||
render json: @status, serializer: OEmbedSerializer, width: 400
|
||||
render json: @status, serializer: OEmbedSerializer
|
||||
else
|
||||
return not_found unless user_signed_in?
|
||||
|
||||
|
||||
@@ -20,11 +20,6 @@ class Auth::SessionsController < Devise::SessionsController
|
||||
p.form_action(false)
|
||||
end
|
||||
|
||||
def check_suspicious!
|
||||
user = find_user
|
||||
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
||||
end
|
||||
|
||||
def create
|
||||
super do |resource|
|
||||
# We only need to call this if this hasn't already been
|
||||
@@ -101,6 +96,11 @@ class Auth::SessionsController < Devise::SessionsController
|
||||
|
||||
private
|
||||
|
||||
def check_suspicious!
|
||||
user = find_user
|
||||
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
||||
end
|
||||
|
||||
def home_paths(resource)
|
||||
paths = [about_path, '/explore']
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ module WebAppControllerConcern
|
||||
policy = ContentSecurityPolicy.new
|
||||
|
||||
if policy.sso_host.present?
|
||||
p.form_action policy.sso_host
|
||||
p.form_action policy.sso_host, -> { "https://#{request.host}/auth/auth/" }
|
||||
else
|
||||
p.form_action :none
|
||||
end
|
||||
@@ -31,7 +31,7 @@ module WebAppControllerConcern
|
||||
def redirect_unauthenticated_to_permalinks!
|
||||
return if user_signed_in? # NOTE: Different from upstream because we allow moved users to log in
|
||||
|
||||
permalink_redirector = PermalinkRedirector.new(request.path)
|
||||
permalink_redirector = PermalinkRedirector.new(request.original_fullpath)
|
||||
return if permalink_redirector.redirect_path.blank?
|
||||
|
||||
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?
|
||||
|
||||
@@ -15,7 +15,7 @@ module Settings
|
||||
end
|
||||
|
||||
def create
|
||||
session[:new_otp_secret] = User.generate_otp_secret(32)
|
||||
session[:new_otp_secret] = User.generate_otp_secret
|
||||
|
||||
redirect_to new_settings_two_factor_authentication_confirmation_path
|
||||
end
|
||||
|
||||
@@ -7,7 +7,23 @@ module WellKnown
|
||||
def show
|
||||
@webfinger_template = "#{webfinger_url}?resource={uri}"
|
||||
expires_in 3.days, public: true
|
||||
render content_type: 'application/xrd+xml', formats: [:xml]
|
||||
|
||||
respond_to do |format|
|
||||
format.any do
|
||||
render content_type: 'application/xrd+xml', formats: [:xml]
|
||||
end
|
||||
|
||||
format.json do
|
||||
render json: {
|
||||
links: [
|
||||
{
|
||||
rel: 'lrdd',
|
||||
template: @webfinger_template,
|
||||
},
|
||||
],
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,4 +35,11 @@ module Admin::ActionLogsHelper
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sorted_action_log_types
|
||||
Admin::ActionLogFilter::ACTION_TYPE_MAP
|
||||
.keys
|
||||
.map { |key| [I18n.t("admin.action_logs.action_types.#{key}"), key] }
|
||||
.sort_by(&:first)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,6 +18,11 @@ module Admin::DashboardHelper
|
||||
end
|
||||
end
|
||||
|
||||
def date_range(range)
|
||||
[l(range.first), l(range.last)]
|
||||
.join(' - ')
|
||||
end
|
||||
|
||||
def relevant_account_timestamp(account)
|
||||
timestamp, exact = if account.user_current_sign_in_at && account.user_current_sign_in_at < 24.hours.ago
|
||||
[account.user_current_sign_in_at, true]
|
||||
@@ -25,6 +30,8 @@ module Admin::DashboardHelper
|
||||
[account.user_current_sign_in_at, false]
|
||||
elsif account.user_pending?
|
||||
[account.user_created_at, true]
|
||||
elsif account.suspended_at.present? && account.local? && account.user.nil?
|
||||
[account.suspended_at, true]
|
||||
elsif account.last_status_at.present?
|
||||
[account.last_status_at, true]
|
||||
else
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ApplicationHelper
|
||||
DANGEROUS_SCOPES = %w(
|
||||
read
|
||||
write
|
||||
follow
|
||||
).freeze
|
||||
|
||||
RTL_LOCALES = %i(
|
||||
ar
|
||||
ckb
|
||||
@@ -95,8 +89,11 @@ module ApplicationHelper
|
||||
Rails.env.production? ? site_title : "#{site_title} (Dev)"
|
||||
end
|
||||
|
||||
def class_for_scope(scope)
|
||||
'scope-danger' if DANGEROUS_SCOPES.include?(scope.to_s)
|
||||
def label_for_scope(scope)
|
||||
safe_join [
|
||||
tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }),
|
||||
tag.span(t("doorkeeper.scopes.#{scope}"), class: :hint),
|
||||
]
|
||||
end
|
||||
|
||||
def can?(action, record)
|
||||
@@ -244,6 +241,10 @@ module ApplicationHelper
|
||||
full_asset_url(instance_presenter.mascot&.file&.url || frontend_asset_path('images/elephant_ui_plane.svg'))
|
||||
end
|
||||
|
||||
def copyable_input(options = {})
|
||||
tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options)
|
||||
end
|
||||
|
||||
# glitch-soc addition to handle the multiple flavors
|
||||
def preload_locale_pack
|
||||
supported_locales = Themes.instance.flavour(current_flavour)['locales']
|
||||
|
||||
@@ -24,23 +24,6 @@ module ContextHelper
|
||||
indexable: { 'toot' => 'http://joinmastodon.org/ns#', 'indexable' => 'toot:indexable' },
|
||||
memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' },
|
||||
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
||||
olm: {
|
||||
'toot' => 'http://joinmastodon.org/ns#',
|
||||
'Device' => 'toot:Device',
|
||||
'Ed25519Signature' => 'toot:Ed25519Signature',
|
||||
'Ed25519Key' => 'toot:Ed25519Key',
|
||||
'Curve25519Key' => 'toot:Curve25519Key',
|
||||
'EncryptedMessage' => 'toot:EncryptedMessage',
|
||||
'publicKeyBase64' => 'toot:publicKeyBase64',
|
||||
'deviceId' => 'toot:deviceId',
|
||||
'claim' => { '@type' => '@id', '@id' => 'toot:claim' },
|
||||
'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' },
|
||||
'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' },
|
||||
'devices' => { '@type' => '@id', '@id' => 'toot:devices' },
|
||||
'messageFranking' => 'toot:messageFranking',
|
||||
'messageType' => 'toot:messageType',
|
||||
'cipherText' => 'toot:cipherText',
|
||||
},
|
||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
||||
}.freeze
|
||||
|
||||
@@ -10,16 +10,17 @@ module SettingsHelper
|
||||
end
|
||||
|
||||
def featured_tags_hint(recently_used_tags)
|
||||
safe_join(
|
||||
[
|
||||
t('simple_form.hints.featured_tag.name'),
|
||||
safe_join(
|
||||
links_for_featured_tags(recently_used_tags),
|
||||
', '
|
||||
),
|
||||
],
|
||||
' '
|
||||
)
|
||||
recently_used_tags.present? &&
|
||||
safe_join(
|
||||
[
|
||||
t('simple_form.hints.featured_tag.name'),
|
||||
safe_join(
|
||||
links_for_featured_tags(recently_used_tags),
|
||||
', '
|
||||
),
|
||||
],
|
||||
' '
|
||||
)
|
||||
end
|
||||
|
||||
def session_device_icon(session)
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module WebfingerHelper
|
||||
def webfinger!(uri)
|
||||
Webfinger.new(uri).perform
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,5 @@
|
||||
import { browserHistory } from 'flavours/glitch/components/router';
|
||||
import { debounceWithDispatchAndArguments } from 'flavours/glitch/utils/debounce';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
@@ -462,6 +463,20 @@ export function expandFollowingFail(id, error) {
|
||||
};
|
||||
}
|
||||
|
||||
const debouncedFetchRelationships = debounceWithDispatchAndArguments((dispatch, ...newAccountIds) => {
|
||||
if (newAccountIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchRelationshipsRequest(newAccountIds));
|
||||
|
||||
api().get(`/api/v1/accounts/relationships?with_suspended=true&${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
|
||||
dispatch(fetchRelationshipsSuccess({ relationships: response.data }));
|
||||
}).catch(error => {
|
||||
dispatch(fetchRelationshipsFail(error));
|
||||
});
|
||||
}, { delay: 500 });
|
||||
|
||||
export function fetchRelationships(accountIds) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
@@ -473,13 +488,7 @@ export function fetchRelationships(accountIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchRelationshipsRequest(newAccountIds));
|
||||
|
||||
api().get(`/api/v1/accounts/relationships?with_suspended=true&${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
|
||||
dispatch(fetchRelationshipsSuccess({ relationships: response.data }));
|
||||
}).catch(error => {
|
||||
dispatch(fetchRelationshipsFail(error));
|
||||
});
|
||||
debouncedFetchRelationships(dispatch, ...newAccountIds);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
const messages = defineMessages({
|
||||
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
||||
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
|
||||
@@ -50,6 +52,11 @@ export const showAlertForError = (error, skipNotFound = false) => {
|
||||
});
|
||||
}
|
||||
|
||||
// An aborted request, e.g. due to reloading the browser window, it not really error
|
||||
if (error.code === AxiosError.ECONNABORTED) {
|
||||
return { type: ALERT_NOOP };
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
|
||||
return showAlert({
|
||||
|
||||
@@ -37,8 +37,7 @@ export const synchronouslySubmitMarkers = createAppAsyncThunk(
|
||||
});
|
||||
|
||||
return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
} else if ('navigator' && 'sendBeacon' in navigator) {
|
||||
} else if ('sendBeacon' in navigator) {
|
||||
// Failing that, we can use sendBeacon, but we have to encode the data as
|
||||
// FormData for DoorKeeper to recognize the token.
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -68,10 +68,19 @@ function dispatchAssociatedRecords(
|
||||
dispatch(importFetchedStatuses(fetchedStatuses));
|
||||
}
|
||||
|
||||
const supportedGroupedNotificationTypes = ['favourite', 'reblog'];
|
||||
|
||||
export function shouldGroupNotificationType(type: string) {
|
||||
return supportedGroupedNotificationTypes.includes(type);
|
||||
}
|
||||
|
||||
export const fetchNotifications = createDataLoadingThunk(
|
||||
'notificationGroups/fetch',
|
||||
async (_params, { getState }) =>
|
||||
apiFetchNotificationGroups({ exclude_types: getExcludedTypes(getState()) }),
|
||||
apiFetchNotificationGroups({
|
||||
grouped_types: supportedGroupedNotificationTypes,
|
||||
exclude_types: getExcludedTypes(getState()),
|
||||
}),
|
||||
({ notifications, accounts, statuses }, { dispatch }) => {
|
||||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch(importFetchedStatuses(statuses));
|
||||
@@ -93,6 +102,7 @@ export const fetchNotificationsGap = createDataLoadingThunk(
|
||||
'notificationGroups/fetchGap',
|
||||
async (params: { gap: NotificationGap }, { getState }) =>
|
||||
apiFetchNotificationGroups({
|
||||
grouped_types: supportedGroupedNotificationTypes,
|
||||
max_id: params.gap.maxId,
|
||||
exclude_types: getExcludedTypes(getState()),
|
||||
}),
|
||||
@@ -109,6 +119,7 @@ export const pollRecentNotifications = createDataLoadingThunk(
|
||||
'notificationGroups/pollRecentNotifications',
|
||||
async (_params, { getState }) => {
|
||||
return apiFetchNotificationGroups({
|
||||
grouped_types: supportedGroupedNotificationTypes,
|
||||
max_id: undefined,
|
||||
exclude_types: getExcludedTypes(getState()),
|
||||
// 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),
|
||||
);
|
||||
|
||||
export const decreasePendingNotificationsCount = createAction<number>(
|
||||
'notificationPolicy/decreasePendingNotificationCount',
|
||||
export const decreasePendingRequestsCount = createAction<number>(
|
||||
'notificationPolicy/decreasePendingRequestsCount',
|
||||
);
|
||||
|
||||
@@ -13,11 +13,11 @@ import type {
|
||||
ApiNotificationJSON,
|
||||
} from 'flavours/glitch/api_types/notifications';
|
||||
import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses';
|
||||
import type { AppDispatch, RootState } from 'flavours/glitch/store';
|
||||
import type { AppDispatch } from 'flavours/glitch/store';
|
||||
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
import { decreasePendingNotificationsCount } from './notification_policies';
|
||||
import { decreasePendingRequestsCount } from './notification_policies';
|
||||
|
||||
// TODO: refactor with notification_groups
|
||||
function dispatchAssociatedRecords(
|
||||
@@ -169,19 +169,11 @@ export const expandNotificationsForRequest = createDataLoadingThunk(
|
||||
},
|
||||
);
|
||||
|
||||
const selectNotificationCountForRequest = (state: RootState, id: string) => {
|
||||
const requests = state.notificationRequests.items;
|
||||
const thisRequest = requests.find((request) => request.id === id);
|
||||
return thisRequest ? thisRequest.notifications_count : 0;
|
||||
};
|
||||
|
||||
export const acceptNotificationRequest = createDataLoadingThunk(
|
||||
'notificationRequest/accept',
|
||||
({ id }: { id: string }) => apiAcceptNotificationRequest(id),
|
||||
(_data, { dispatch, getState, discardLoadData, actionArg: { id } }) => {
|
||||
const count = selectNotificationCountForRequest(getState(), id);
|
||||
|
||||
dispatch(decreasePendingNotificationsCount(count));
|
||||
(_data, { dispatch, discardLoadData }) => {
|
||||
dispatch(decreasePendingRequestsCount(1));
|
||||
|
||||
// The payload is not used in any functions
|
||||
return discardLoadData;
|
||||
@@ -191,10 +183,8 @@ export const acceptNotificationRequest = createDataLoadingThunk(
|
||||
export const dismissNotificationRequest = createDataLoadingThunk(
|
||||
'notificationRequest/dismiss',
|
||||
({ id }: { id: string }) => apiDismissNotificationRequest(id),
|
||||
(_data, { dispatch, getState, discardLoadData, actionArg: { id } }) => {
|
||||
const count = selectNotificationCountForRequest(getState(), id);
|
||||
|
||||
dispatch(decreasePendingNotificationsCount(count));
|
||||
(_data, { dispatch, discardLoadData }) => {
|
||||
dispatch(decreasePendingRequestsCount(1));
|
||||
|
||||
// The payload is not used in any functions
|
||||
return discardLoadData;
|
||||
@@ -204,13 +194,8 @@ export const dismissNotificationRequest = createDataLoadingThunk(
|
||||
export const acceptNotificationRequests = createDataLoadingThunk(
|
||||
'notificationRequests/acceptBulk',
|
||||
({ ids }: { ids: string[] }) => apiAcceptNotificationRequests(ids),
|
||||
(_data, { dispatch, getState, discardLoadData, actionArg: { ids } }) => {
|
||||
const count = ids.reduce(
|
||||
(count, id) => count + selectNotificationCountForRequest(getState(), id),
|
||||
0,
|
||||
);
|
||||
|
||||
dispatch(decreasePendingNotificationsCount(count));
|
||||
(_data, { dispatch, discardLoadData, actionArg: { ids } }) => {
|
||||
dispatch(decreasePendingRequestsCount(ids.length));
|
||||
|
||||
// The payload is not used in any functions
|
||||
return discardLoadData;
|
||||
@@ -220,13 +205,8 @@ export const acceptNotificationRequests = createDataLoadingThunk(
|
||||
export const dismissNotificationRequests = createDataLoadingThunk(
|
||||
'notificationRequests/dismissBulk',
|
||||
({ ids }: { ids: string[] }) => apiDismissNotificationRequests(ids),
|
||||
(_data, { dispatch, getState, discardLoadData, actionArg: { ids } }) => {
|
||||
const count = ids.reduce(
|
||||
(count, id) => count + selectNotificationCountForRequest(getState(), id),
|
||||
0,
|
||||
);
|
||||
|
||||
dispatch(decreasePendingNotificationsCount(count));
|
||||
(_data, { dispatch, discardLoadData, actionArg: { ids } }) => {
|
||||
dispatch(decreasePendingRequestsCount(ids.length));
|
||||
|
||||
// The payload is not used in any functions
|
||||
return discardLoadData;
|
||||
|
||||
@@ -10,7 +10,7 @@ import api, { getLinks } from '../api';
|
||||
import { unescapeHTML } from '../utils/html';
|
||||
import { requestNotificationPermission } from '../utils/notifications';
|
||||
|
||||
import { fetchFollowRequests, fetchRelationships } from './accounts';
|
||||
import { fetchFollowRequests } from './accounts';
|
||||
import {
|
||||
importFetchedAccount,
|
||||
importFetchedAccounts,
|
||||
@@ -68,14 +68,6 @@ defineMessages({
|
||||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
||||
});
|
||||
|
||||
const fetchRelatedRelationships = (dispatch, notifications) => {
|
||||
const accountIds = notifications.filter(item => ['follow', 'follow_request', 'admin.sign_up'].indexOf(item.type) !== -1).map(item => item.account.id);
|
||||
|
||||
if (accountIds.length > 0) {
|
||||
dispatch(fetchRelationships(accountIds));
|
||||
}
|
||||
};
|
||||
|
||||
export const loadPending = () => ({
|
||||
type: NOTIFICATIONS_LOAD_PENDING,
|
||||
});
|
||||
@@ -118,8 +110,6 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||
|
||||
|
||||
dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered}));
|
||||
|
||||
fetchRelatedRelationships(dispatch, [notification]);
|
||||
} else if (playSound && !filtered) {
|
||||
dispatch({
|
||||
type: NOTIFICATIONS_UPDATE_NOOP,
|
||||
@@ -211,7 +201,6 @@ export function expandNotifications({ maxId = undefined, forceLoad = false }) {
|
||||
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
|
||||
|
||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
||||
fetchRelatedRelationships(dispatch, response.data);
|
||||
dispatch(submitMarkers());
|
||||
} catch(error) {
|
||||
dispatch(expandNotificationsFail(error, isLoadingMore));
|
||||
|
||||
@@ -42,6 +42,9 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => {
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function api(withAuthorization = true) {
|
||||
return axios.create({
|
||||
transitional: {
|
||||
clarifyTimeoutError: true,
|
||||
},
|
||||
headers: {
|
||||
...csrfHeader,
|
||||
...(withAuthorization ? authorizationTokenFromInitialState() : {}),
|
||||
@@ -67,6 +70,7 @@ export async function apiRequest<ApiResponse = unknown>(
|
||||
args: {
|
||||
params?: RequestParamsOrData;
|
||||
data?: RequestParamsOrData;
|
||||
timeout?: number;
|
||||
} = {},
|
||||
) {
|
||||
const { data } = await api().request<ApiResponse>({
|
||||
|
||||
@@ -31,6 +31,7 @@ export const apiFetchNotifications = async (
|
||||
|
||||
export const apiFetchNotificationGroups = async (params?: {
|
||||
url?: string;
|
||||
grouped_types?: string[];
|
||||
exclude_types?: string[];
|
||||
max_id?: string;
|
||||
since_id?: string;
|
||||
@@ -91,5 +92,5 @@ export const apiAcceptNotificationRequests = async (id: string[]) => {
|
||||
};
|
||||
|
||||
export const apiDismissNotificationRequests = async (id: string[]) => {
|
||||
return apiRequestPost('v1/notifications/dismiss/dismiss', { id });
|
||||
return apiRequestPost('v1/notifications/requests/dismiss', { id });
|
||||
};
|
||||
|
||||
67
app/javascript/flavours/glitch/components/alt_text_badge.tsx
Normal file
67
app/javascript/flavours/glitch/components/alt_text_badge.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
import type {
|
||||
OffsetValue,
|
||||
UsePopperOptions,
|
||||
} from 'react-overlays/esm/usePopper';
|
||||
|
||||
const offset = [0, 4] as OffsetValue;
|
||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||
|
||||
export const AltTextBadge: React.FC<{
|
||||
description: string;
|
||||
}> = ({ description }) => {
|
||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setOpen((v) => !v);
|
||||
}, [setOpen]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
className='media-gallery__alt__label'
|
||||
onClick={handleClick}
|
||||
>
|
||||
ALT
|
||||
</button>
|
||||
|
||||
<Overlay
|
||||
rootClose
|
||||
onHide={handleClose}
|
||||
show={open}
|
||||
target={anchorRef.current}
|
||||
placement='top-end'
|
||||
flip
|
||||
offset={offset}
|
||||
popperConfig={popperConfig}
|
||||
>
|
||||
{({ props }) => (
|
||||
<div {...props} className='hover-card-controller'>
|
||||
<div
|
||||
className='media-gallery__alt__popover dropdown-animation'
|
||||
role='tooltip'
|
||||
>
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id='alt_text_badge.title'
|
||||
defaultMessage='Alt text'
|
||||
/>
|
||||
</h4>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useHovering } from 'flavours/glitch/hooks/useHovering';
|
||||
import { autoPlayGif } from 'flavours/glitch/initial_state';
|
||||
import type { Account } from 'flavours/glitch/models/account';
|
||||
|
||||
import { useHovering } from '../hooks/useHovering';
|
||||
import { autoPlayGif } from '../initial_state';
|
||||
|
||||
interface Props {
|
||||
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
|
||||
size: number;
|
||||
@@ -25,6 +26,8 @@ export const Avatar: React.FC<Props> = ({
|
||||
counterBorderColor,
|
||||
}) => {
|
||||
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const style = {
|
||||
...styleFromParent,
|
||||
@@ -37,17 +40,29 @@ export const Avatar: React.FC<Props> = ({
|
||||
? account?.get('avatar')
|
||||
: account?.get('avatar_static');
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
setLoading(false);
|
||||
}, [setLoading]);
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
setError(true);
|
||||
}, [setError]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('account__avatar', {
|
||||
'account__avatar-inline': inline,
|
||||
'account__avatar--inline': inline,
|
||||
'account__avatar--loading': loading,
|
||||
})}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={style}
|
||||
data-avatar-of={account && `@${account.get('acct')}`}
|
||||
>
|
||||
{src && <img src={src} alt='' />}
|
||||
{src && !error && (
|
||||
<img src={src} alt='' onLoad={handleLoad} onError={handleError} />
|
||||
)}
|
||||
|
||||
{counter && (
|
||||
<div
|
||||
className='account__avatar__counter'
|
||||
|
||||
@@ -7,6 +7,7 @@ interface BaseProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
block?: boolean;
|
||||
secondary?: boolean;
|
||||
dangerous?: boolean;
|
||||
}
|
||||
|
||||
interface PropsChildren extends PropsWithChildren<BaseProps> {
|
||||
@@ -26,6 +27,7 @@ export const Button: React.FC<Props> = ({
|
||||
disabled,
|
||||
block,
|
||||
secondary,
|
||||
dangerous,
|
||||
className,
|
||||
title,
|
||||
text,
|
||||
@@ -46,6 +48,7 @@ export const Button: React.FC<Props> = ({
|
||||
className={classNames('button', className, {
|
||||
'button-secondary': secondary,
|
||||
'button--block': block,
|
||||
'button--dangerous': dangerous,
|
||||
})}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -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 { AltTextBadge } from 'flavours/glitch/components/alt_text_badge';
|
||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||
import { formatTime } from 'flavours/glitch/features/video';
|
||||
|
||||
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
|
||||
|
||||
@@ -58,7 +60,7 @@ class Item extends PureComponent {
|
||||
|
||||
hoverToPlay () {
|
||||
const { attachment } = this.props;
|
||||
return !this.getAutoPlay() && attachment.get('type') === 'gifv';
|
||||
return !this.getAutoPlay() && ['gifv', 'video'].includes(attachment.get('type'));
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
@@ -97,7 +99,7 @@ class Item extends PureComponent {
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -152,10 +154,15 @@ class Item extends PureComponent {
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
} else if (attachment.get('type') === 'gifv') {
|
||||
} else if (['gifv', 'video'].includes(attachment.get('type'))) {
|
||||
const autoPlay = this.getAutoPlay();
|
||||
const duration = attachment.getIn(['meta', 'original', 'duration']);
|
||||
|
||||
badges.push(<span key='gif' className='media-gallery__gifv__label'>GIF</span>);
|
||||
if (attachment.get('type') === 'gifv') {
|
||||
badges.push(<span key='gif' className='media-gallery__alt__label media-gallery__alt__label--non-interactive'>GIF</span>);
|
||||
} else {
|
||||
badges.push(<span key='video' className='media-gallery__alt__label media-gallery__alt__label--non-interactive'>{formatTime(Math.floor(duration))}</span>);
|
||||
}
|
||||
|
||||
thumbnail = (
|
||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
||||
@@ -169,6 +176,7 @@ class Item extends PureComponent {
|
||||
onClick={this.handleClick}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
onLoadedData={this.handleImageLoad}
|
||||
autoPlay={autoPlay}
|
||||
playsInline
|
||||
loop
|
||||
@@ -190,7 +198,7 @@ class Item extends PureComponent {
|
||||
|
||||
{visible && thumbnail}
|
||||
|
||||
{badges && (
|
||||
{visible && badges && (
|
||||
<div className='media-gallery__item__badges'>
|
||||
{badges}
|
||||
</div>
|
||||
@@ -348,14 +356,14 @@ class MediaGallery extends PureComponent {
|
||||
|
||||
return (
|
||||
<div className={computedClass} style={style} ref={this.handleRef}>
|
||||
{children}
|
||||
|
||||
{(!visible || uncached) && (
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
|
||||
{spoilerButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{(visible && !uncached) && (
|
||||
<div className='media-gallery__actions'>
|
||||
<button className='media-gallery__actions__pill' onClick={this.handleOpen}><FormattedMessage id='media_gallery.hide' defaultMessage='Hide' /></button>
|
||||
|
||||
@@ -153,7 +153,7 @@ class ModalRoot extends PureComponent {
|
||||
return (
|
||||
<div className='modal-root' ref={this.setRef}>
|
||||
<div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
|
||||
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.7)` : null }} />
|
||||
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.9)` : null }} />
|
||||
<div role='dialog' className='modal-root__container'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,22 +4,22 @@ import AccountNavigation from 'flavours/glitch/features/account/navigation';
|
||||
import Trends from 'flavours/glitch/features/getting_started/containers/trends_container';
|
||||
import { showTrends } from 'flavours/glitch/initial_state';
|
||||
|
||||
const DefaultNavigation: React.FC = () =>
|
||||
showTrends ? (
|
||||
<>
|
||||
<div className='flex-spacer' />
|
||||
<Trends />
|
||||
</>
|
||||
) : null;
|
||||
const DefaultNavigation: React.FC = () => (showTrends ? <Trends /> : null);
|
||||
|
||||
export const NavigationPortal: React.FC = () => (
|
||||
<Switch>
|
||||
<Route path='/@:acct' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/tagged/:tagged?' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/with_replies' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/followers' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/following' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/media' exact component={AccountNavigation} />
|
||||
<Route component={DefaultNavigation} />
|
||||
</Switch>
|
||||
<div className='navigation-panel__portal'>
|
||||
<Switch>
|
||||
<Route path='/@:acct' exact component={AccountNavigation} />
|
||||
<Route
|
||||
path='/@:acct/tagged/:tagged?'
|
||||
exact
|
||||
component={AccountNavigation}
|
||||
/>
|
||||
<Route path='/@:acct/with_replies' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/followers' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/following' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/media' exact component={AccountNavigation} />
|
||||
<Route component={DefaultNavigation} />
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -51,7 +51,8 @@ function normalizePath(
|
||||
|
||||
if (
|
||||
layoutFromWindow() === 'multi-column' &&
|
||||
!location.pathname?.startsWith('/deck')
|
||||
location.pathname &&
|
||||
!location.pathname.startsWith('/deck')
|
||||
) {
|
||||
location.pathname = `/deck${location.pathname}`;
|
||||
}
|
||||
|
||||
@@ -648,6 +648,27 @@ class Status extends ImmutablePureComponent {
|
||||
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') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
||||
@@ -703,27 +724,6 @@ class Status extends ImmutablePureComponent {
|
||||
</Bundle>,
|
||||
);
|
||||
mediaIcons.push('video-camera');
|
||||
} else { // Media type is 'image' or 'gifv'
|
||||
media.push(
|
||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
||||
{Component => (
|
||||
<Component
|
||||
media={attachments}
|
||||
lang={language}
|
||||
sensitive={status.get('sensitive')}
|
||||
letterbox={settings.getIn(['media', 'letterbox'])}
|
||||
fullwidth={!rootId && settings.getIn(['media', 'fullwidth'])}
|
||||
hidden={isCollapsed || !isExpanded}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
/>
|
||||
)}
|
||||
</Bundle>,
|
||||
);
|
||||
mediaIcons.push('picture-o');
|
||||
}
|
||||
|
||||
if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
|
||||
|
||||
@@ -315,36 +315,48 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
const filterButton = this.props.onFilter && (
|
||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' iconComponent={VisibilityIcon} onClick={this.handleHideClick} />
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' iconComponent={VisibilityIcon} onClick={this.handleHideClick} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<IconButton
|
||||
className='status__action-bar-button'
|
||||
title={replyTitle}
|
||||
icon={replyIcon}
|
||||
iconComponent={replyIconComponent}
|
||||
onClick={this.handleReplyClick}
|
||||
counter={showReplyCount ? status.get('replies_count') : undefined}
|
||||
obfuscateCount
|
||||
/>
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
<IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton
|
||||
className='status__action-bar-button'
|
||||
title={replyTitle}
|
||||
icon={replyIcon}
|
||||
iconComponent={replyIconComponent}
|
||||
onClick={this.handleReplyClick}
|
||||
counter={showReplyCount ? status.get('replies_count') : undefined}
|
||||
obfuscateCount
|
||||
/>
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
|
||||
</div>
|
||||
|
||||
{filterButton}
|
||||
|
||||
<DropdownMenuContainer
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
size={18}
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
ariaLabel={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<DropdownMenuContainer
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
size={18}
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
ariaLabel={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='status__action-bar-spacer' />
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
|
||||
|
||||
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 MovieIcon from '@/material-icons/400-24px/movie.svg?react';
|
||||
import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react';
|
||||
import { ContentWarning } from 'flavours/glitch/components/content_warning';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
import { autoPlayGif, languages as preloadedLanguages } from 'flavours/glitch/initial_state';
|
||||
@@ -350,7 +351,7 @@ class StatusContent extends PureComponent {
|
||||
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
|
||||
|
||||
const content = { __html: statusContent ?? getStatusContent(status) };
|
||||
const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') };
|
||||
const spoilerHtml = status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml');
|
||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||
const classNames = classnames('status__content', {
|
||||
'status__content--with-action': parseClick && !disabled,
|
||||
@@ -375,45 +376,26 @@ class StatusContent extends PureComponent {
|
||||
</Permalink>
|
||||
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
|
||||
|
||||
let toggleText = null;
|
||||
if (hidden) {
|
||||
toggleText = [
|
||||
<FormattedMessage
|
||||
id='status.show_more'
|
||||
defaultMessage='Show more'
|
||||
key='0'
|
||||
/>,
|
||||
];
|
||||
if (mediaIcons) {
|
||||
const mediaComponents = {
|
||||
'link': LinkIcon,
|
||||
'picture-o': ImageIcon,
|
||||
'tasks': InsertChartIcon,
|
||||
'video-camera': MovieIcon,
|
||||
'music': MusicNoteIcon,
|
||||
};
|
||||
let spoilerIcons = [];
|
||||
if (hidden && mediaIcons) {
|
||||
const mediaComponents = {
|
||||
'link': LinkIcon,
|
||||
'picture-o': ImageIcon,
|
||||
'tasks': InsertChartIcon,
|
||||
'video-camera': MovieIcon,
|
||||
'music': MusicNoteIcon,
|
||||
};
|
||||
|
||||
mediaIcons.forEach((mediaIcon, idx) => {
|
||||
toggleText.push(
|
||||
<Icon
|
||||
fixedWidth
|
||||
className='status__content__spoiler-icon'
|
||||
id={mediaIcon}
|
||||
icon={mediaComponents[mediaIcon]}
|
||||
aria-hidden='true'
|
||||
key={`icon-${idx}`}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toggleText = (
|
||||
<FormattedMessage
|
||||
id='status.show_less'
|
||||
defaultMessage='Show less'
|
||||
key='0'
|
||||
spoilerIcons = mediaIcons.map((mediaIcon) => (
|
||||
<Icon
|
||||
fixedWidth
|
||||
className='status__content__spoiler-icon'
|
||||
id={mediaIcon}
|
||||
icon={mediaComponents[mediaIcon]}
|
||||
aria-hidden='true'
|
||||
key={`icon-${mediaIcon}`}
|
||||
/>
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
@@ -422,15 +404,7 @@ class StatusContent extends PureComponent {
|
||||
|
||||
return (
|
||||
<div className={classNames} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||
<p
|
||||
style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={language} />
|
||||
{' '}
|
||||
<button type='button' className='status__content__spoiler-link' onClick={this.handleSpoilerClick} aria-expanded={!hidden}>
|
||||
{toggleText}
|
||||
</button>
|
||||
</p>
|
||||
<ContentWarning text={spoilerHtml} expanded={!hidden} onClick={this.handleSpoilerClick} icons={spoilerIcons} />
|
||||
|
||||
{mentionsPlaceholder}
|
||||
|
||||
|
||||
@@ -43,10 +43,7 @@ class AccountNavigation extends PureComponent {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex-spacer' />
|
||||
<FeaturedTags accountId={accountId} tagged={tagged} />
|
||||
</>
|
||||
<FeaturedTags accountId={accountId} tagged={tagged} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import AudiotrackIcon from '@/material-icons/400-24px/music_note.svg?react';
|
||||
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow.svg?react';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
|
||||
|
||||
export default class MediaItem extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
attachment: ImmutablePropTypes.map.isRequired,
|
||||
displayWidth: PropTypes.number.isRequired,
|
||||
onOpenMedia: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
handleImageLoad = () => {
|
||||
this.setState({ loaded: true });
|
||||
};
|
||||
|
||||
handleMouseEnter = e => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.play();
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseLeave = e => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.pause();
|
||||
e.target.currentTime = 0;
|
||||
}
|
||||
};
|
||||
|
||||
hoverToPlay () {
|
||||
return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.state.visible) {
|
||||
this.props.onOpenMedia(this.props.attachment);
|
||||
} else {
|
||||
this.setState({ visible: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { attachment, displayWidth } = this.props;
|
||||
const { visible, loaded } = this.state;
|
||||
|
||||
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
|
||||
const height = width;
|
||||
const status = attachment.get('status');
|
||||
const title = status.get('spoiler_text') || attachment.get('description');
|
||||
|
||||
let thumbnail, label, icon, content;
|
||||
|
||||
if (!visible) {
|
||||
icon = (
|
||||
<span className='account-gallery__item__icons'>
|
||||
<Icon id='eye-slash' icon={VisibilityOffIcon} />
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
if (['audio', 'video'].includes(attachment.get('type'))) {
|
||||
content = (
|
||||
<img
|
||||
src={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||
alt={attachment.get('description')}
|
||||
lang={status.get('language')}
|
||||
onLoad={this.handleImageLoad}
|
||||
/>
|
||||
);
|
||||
|
||||
if (attachment.get('type') === 'audio') {
|
||||
label = <Icon id='music' icon={AudiotrackIcon} />;
|
||||
} else {
|
||||
label = <Icon id='play' icon={PlayArrowIcon} />;
|
||||
}
|
||||
} else if (attachment.get('type') === 'image') {
|
||||
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
|
||||
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
|
||||
const x = ((focusX / 2) + .5) * 100;
|
||||
const y = ((focusY / -2) + .5) * 100;
|
||||
|
||||
content = (
|
||||
<img
|
||||
src={attachment.get('preview_url')}
|
||||
alt={attachment.get('description')}
|
||||
lang={status.get('language')}
|
||||
style={{ objectPosition: `${x}% ${y}%` }}
|
||||
onLoad={this.handleImageLoad}
|
||||
/>
|
||||
);
|
||||
} else if (attachment.get('type') === 'gifv') {
|
||||
content = (
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
aria-label={attachment.get('description')}
|
||||
title={attachment.get('description')}
|
||||
lang={status.get('language')}
|
||||
role='application'
|
||||
src={attachment.get('url')}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
autoPlay={autoPlayGif}
|
||||
playsInline
|
||||
loop
|
||||
muted
|
||||
/>
|
||||
);
|
||||
|
||||
label = 'GIF';
|
||||
}
|
||||
|
||||
thumbnail = (
|
||||
<div className='media-gallery__gifv'>
|
||||
{content}
|
||||
|
||||
{label && (
|
||||
<div className='media-gallery__item__badges'>
|
||||
<span className='media-gallery__gifv__label'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account-gallery__item' style={{ width, height }}>
|
||||
<a className='media-gallery__item-thumbnail' href={status.get('url')} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'>
|
||||
<Blurhash
|
||||
hash={attachment.get('blurhash')}
|
||||
className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })}
|
||||
dummy={!useBlurhash}
|
||||
/>
|
||||
|
||||
{visible ? thumbnail : icon}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import HeadphonesIcon from '@/material-icons/400-24px/headphones-fill.svg?react';
|
||||
import MovieIcon from '@/material-icons/400-24px/movie-fill.svg?react';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import { AltTextBadge } from 'flavours/glitch/components/alt_text_badge';
|
||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { formatTime } from 'flavours/glitch/features/video';
|
||||
import {
|
||||
autoPlayGif,
|
||||
displayMedia,
|
||||
useBlurhash,
|
||||
} from 'flavours/glitch/initial_state';
|
||||
import type { Status, MediaAttachment } from 'flavours/glitch/models/status';
|
||||
|
||||
export const MediaItem: React.FC<{
|
||||
attachment: MediaAttachment;
|
||||
onOpenMedia: (arg0: MediaAttachment) => void;
|
||||
}> = ({ attachment, onOpenMedia }) => {
|
||||
const [visible, setVisible] = useState(
|
||||
(displayMedia !== 'hide_all' &&
|
||||
!attachment.getIn(['status', 'sensitive'])) ||
|
||||
displayMedia === 'show_all',
|
||||
);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const handleImageLoad = useCallback(() => {
|
||||
setLoaded(true);
|
||||
}, [setLoaded]);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
if (e.target instanceof HTMLVideoElement) {
|
||||
void e.target.play();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
if (e.target instanceof HTMLVideoElement) {
|
||||
e.target.pause();
|
||||
e.target.currentTime = 0;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (visible) {
|
||||
onOpenMedia(attachment);
|
||||
} else {
|
||||
setVisible(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[attachment, visible, onOpenMedia, setVisible],
|
||||
);
|
||||
|
||||
const status = attachment.get('status') as Status;
|
||||
const description = (attachment.getIn(['translation', 'description']) ||
|
||||
attachment.get('description')) as string | undefined;
|
||||
const previewUrl = attachment.get('preview_url') as string;
|
||||
const fullUrl = attachment.get('url') as string;
|
||||
const avatarUrl = status.getIn(['account', 'avatar_static']) as string;
|
||||
const lang = status.get('language') as string;
|
||||
const blurhash = attachment.get('blurhash') as string;
|
||||
const statusUrl = status.get('url') as string;
|
||||
const type = attachment.get('type') as string;
|
||||
|
||||
let thumbnail;
|
||||
|
||||
const badges = [];
|
||||
|
||||
if (description && description.length > 0) {
|
||||
badges.push(<AltTextBadge key='alt' description={description} />);
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
thumbnail = (
|
||||
<div className='media-gallery__item__overlay'>
|
||||
<Icon id='eye-slash' icon={VisibilityOffIcon} />
|
||||
</div>
|
||||
);
|
||||
} else if (type === 'audio') {
|
||||
thumbnail = (
|
||||
<>
|
||||
<img
|
||||
src={previewUrl || avatarUrl}
|
||||
alt={description}
|
||||
title={description}
|
||||
lang={lang}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
|
||||
<div className='media-gallery__item__overlay media-gallery__item__overlay--corner'>
|
||||
<Icon id='music' icon={HeadphonesIcon} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else if (type === 'image') {
|
||||
const focusX = (attachment.getIn(['meta', 'focus', 'x']) || 0) as number;
|
||||
const focusY = (attachment.getIn(['meta', 'focus', 'y']) || 0) as number;
|
||||
const x = (focusX / 2 + 0.5) * 100;
|
||||
const y = (focusY / -2 + 0.5) * 100;
|
||||
|
||||
thumbnail = (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={description}
|
||||
title={description}
|
||||
lang={lang}
|
||||
style={{ objectPosition: `${x}% ${y}%` }}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
);
|
||||
} else if (['video', 'gifv'].includes(type)) {
|
||||
const duration = attachment.getIn([
|
||||
'meta',
|
||||
'original',
|
||||
'duration',
|
||||
]) as number;
|
||||
|
||||
thumbnail = (
|
||||
<div className='media-gallery__gifv'>
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
aria-label={description}
|
||||
title={description}
|
||||
lang={lang}
|
||||
src={fullUrl}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onLoadedData={handleImageLoad}
|
||||
autoPlay={autoPlayGif}
|
||||
playsInline
|
||||
loop
|
||||
muted
|
||||
/>
|
||||
|
||||
{type === 'video' && (
|
||||
<div className='media-gallery__item__overlay media-gallery__item__overlay--corner'>
|
||||
<Icon id='play' icon={MovieIcon} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (type === 'gifv') {
|
||||
badges.push(
|
||||
<span
|
||||
key='gif'
|
||||
className='media-gallery__alt__label media-gallery__alt__label--non-interactive'
|
||||
>
|
||||
GIF
|
||||
</span>,
|
||||
);
|
||||
} else {
|
||||
badges.push(
|
||||
<span
|
||||
key='video'
|
||||
className='media-gallery__alt__label media-gallery__alt__label--non-interactive'
|
||||
>
|
||||
{formatTime(Math.floor(duration))}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='media-gallery__item media-gallery__item--square'>
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
className={classNames('media-gallery__preview', {
|
||||
'media-gallery__preview--hidden': visible && loaded,
|
||||
})}
|
||||
dummy={!useBlurhash}
|
||||
/>
|
||||
|
||||
<a
|
||||
className='media-gallery__item-thumbnail'
|
||||
href={statusUrl}
|
||||
onClick={handleClick}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{thumbnail}
|
||||
</a>
|
||||
|
||||
{badges.length > 0 && (
|
||||
<div className='media-gallery__item__badges'>{badges}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -20,7 +20,7 @@ import { expandAccountMediaTimeline } from '../../actions/timelines';
|
||||
import HeaderContainer from '../account_timeline/containers/header_container';
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
import MediaItem from './components/media_item';
|
||||
import { MediaItem } from './components/media_item';
|
||||
|
||||
const mapStateToProps = (state, { params: { acct, id } }) => {
|
||||
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
|
||||
|
||||
@@ -21,11 +21,11 @@ const messages = defineMessages({
|
||||
export const SensitiveButton = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const spoilersAlwaysOn = useAppSelector((state) => state.getIn(['local_settings', 'always_show_spoilers_field']));
|
||||
const spoilerText = useAppSelector((state) => state.getIn(['compose', 'spoiler_text']));
|
||||
const sensitive = useAppSelector((state) => state.getIn(['compose', 'sensitive']));
|
||||
const spoiler = useAppSelector((state) => state.getIn(['compose', 'spoiler']));
|
||||
const mediaCount = useAppSelector((state) => state.getIn(['compose', 'media_attachments']).size);
|
||||
const spoilersAlwaysOn = useAppSelector((state) => state.local_settings.getIn(['always_show_spoilers_field']));
|
||||
const spoilerText = useAppSelector((state) => state.compose.get('spoiler_text'));
|
||||
const sensitive = useAppSelector((state) => state.compose.get('sensitive'));
|
||||
const spoiler = useAppSelector((state) => state.compose.get('spoiler'));
|
||||
const mediaCount = useAppSelector((state) => state.compose.get('media_attachments').size);
|
||||
const disabled = spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler;
|
||||
|
||||
const active = sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0);
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
||||
import { undoUploadCompose, initMediaEditModal } from 'flavours/glitch/actions/compose';
|
||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import Motion from 'flavours/glitch/features/ui/util/optional_motion';
|
||||
|
||||
export const Upload = ({ id, onDragStart, onDragEnter, onDragEnd }) => {
|
||||
const dispatch = useDispatch();
|
||||
const media = useSelector(state => state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id));
|
||||
const sensitive = useSelector(state => state.getIn(['compose', 'sensitive']));
|
||||
|
||||
const handleUndoClick = useCallback(() => {
|
||||
dispatch(undoUploadCompose(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleFocalPointClick = useCallback(() => {
|
||||
dispatch(initMediaEditModal(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
onDragStart(id);
|
||||
}, [onDragStart, id]);
|
||||
|
||||
const handleDragEnter = useCallback(() => {
|
||||
onDragEnter(id);
|
||||
}, [onDragEnter, id]);
|
||||
|
||||
if (!media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const focusX = media.getIn(['meta', 'focus', 'x']);
|
||||
const focusY = media.getIn(['meta', 'focus', 'y']);
|
||||
const x = ((focusX / 2) + .5) * 100;
|
||||
const y = ((focusY / -2) + .5) * 100;
|
||||
const missingDescription = (media.get('description') || '').length === 0;
|
||||
|
||||
return (
|
||||
<div className='compose-form__upload' draggable onDragStart={handleDragStart} onDragEnter={handleDragEnter} onDragEnd={onDragEnd}>
|
||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
||||
{({ scale }) => (
|
||||
<div className='compose-form__upload__thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: !sensitive ? `url(${media.get('preview_url')})` : null, backgroundPosition: `${x}% ${y}%` }}>
|
||||
{sensitive && <Blurhash
|
||||
hash={media.get('blurhash')}
|
||||
className='compose-form__upload__preview'
|
||||
/>}
|
||||
|
||||
<div className='compose-form__upload__actions'>
|
||||
<button type='button' className='icon-button compose-form__upload__delete' onClick={handleUndoClick}><Icon icon={CloseIcon} /></button>
|
||||
<button type='button' className='icon-button' onClick={handleFocalPointClick}><Icon icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
|
||||
</div>
|
||||
|
||||
<div className='compose-form__upload__warning'>
|
||||
<button type='button' className={classNames('icon-button', { active: missingDescription })} onClick={handleFocalPointClick}>{missingDescription && <Icon icon={WarningIcon} />} ALT</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Upload.propTypes = {
|
||||
id: PropTypes.string,
|
||||
onDragEnter: PropTypes.func,
|
||||
onDragStart: PropTypes.func,
|
||||
onDragEnd: PropTypes.func,
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
||||
import {
|
||||
undoUploadCompose,
|
||||
initMediaEditModal,
|
||||
} from 'flavours/glitch/actions/compose';
|
||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
export const Upload: React.FC<{
|
||||
id: string;
|
||||
dragging?: boolean;
|
||||
overlay?: boolean;
|
||||
tall?: boolean;
|
||||
wide?: boolean;
|
||||
}> = ({ id, dragging, overlay, tall, wide }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const media = useAppSelector(
|
||||
(state) =>
|
||||
state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
|
||||
.get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
.find((item: MediaAttachment) => item.get('id') === id) as // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
| MediaAttachment
|
||||
| undefined,
|
||||
);
|
||||
const sensitive = useAppSelector(
|
||||
(state) => state.compose.get('sensitive') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
|
||||
const handleUndoClick = useCallback(() => {
|
||||
dispatch(undoUploadCompose(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleFocalPointClick = useCallback(() => {
|
||||
dispatch(initMediaEditModal(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id });
|
||||
|
||||
if (!media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const focusX = media.getIn(['meta', 'focus', 'x']) as number;
|
||||
const focusY = media.getIn(['meta', 'focus', 'y']) as number;
|
||||
const x = (focusX / 2 + 0.5) * 100;
|
||||
const y = (focusY / -2 + 0.5) * 100;
|
||||
const missingDescription =
|
||||
((media.get('description') as string | undefined) ?? '').length === 0;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('compose-form__upload media-gallery__item', {
|
||||
dragging,
|
||||
overlay,
|
||||
'media-gallery__item--tall': tall,
|
||||
'media-gallery__item--wide': wide,
|
||||
})}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div
|
||||
className='compose-form__upload__thumbnail'
|
||||
style={{
|
||||
backgroundImage: !sensitive
|
||||
? `url(${media.get('preview_url') as string})`
|
||||
: undefined,
|
||||
backgroundPosition: `${x}% ${y}%`,
|
||||
}}
|
||||
>
|
||||
{sensitive && (
|
||||
<Blurhash
|
||||
hash={media.get('blurhash') as string}
|
||||
className='compose-form__upload__preview'
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='compose-form__upload__actions'>
|
||||
<button
|
||||
type='button'
|
||||
className='icon-button compose-form__upload__delete'
|
||||
onClick={handleUndoClick}
|
||||
>
|
||||
<Icon id='close' icon={CloseIcon} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='icon-button'
|
||||
onClick={handleFocalPointClick}
|
||||
>
|
||||
<Icon id='edit' icon={EditIcon} />{' '}
|
||||
<FormattedMessage id='upload_form.edit' defaultMessage='Edit' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='compose-form__upload__warning'>
|
||||
<button
|
||||
type='button'
|
||||
className={classNames('icon-button', {
|
||||
active: missingDescription,
|
||||
})}
|
||||
onClick={handleFocalPointClick}
|
||||
>
|
||||
{missingDescription && <Icon id='warning' icon={WarningIcon} />} ALT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import { changeMediaOrder } from 'flavours/glitch/actions/compose';
|
||||
|
||||
import { SensitiveButton } from './sensitive_button';
|
||||
import { Upload } from './upload';
|
||||
import { UploadProgress } from './upload_progress';
|
||||
|
||||
export const UploadForm = () => {
|
||||
const dispatch = useDispatch();
|
||||
const mediaIds = useSelector(state => state.getIn(['compose', 'media_attachments']).map(item => item.get('id')));
|
||||
const active = useSelector(state => state.getIn(['compose', 'is_uploading']));
|
||||
const progress = useSelector(state => state.getIn(['compose', 'progress']));
|
||||
const isProcessing = useSelector(state => state.getIn(['compose', 'is_processing']));
|
||||
|
||||
const dragItem = useRef();
|
||||
const dragOverItem = useRef();
|
||||
|
||||
const handleDragStart = useCallback(id => {
|
||||
dragItem.current = id;
|
||||
}, [dragItem]);
|
||||
|
||||
const handleDragEnter = useCallback(id => {
|
||||
dragOverItem.current = id;
|
||||
}, [dragOverItem]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
dispatch(changeMediaOrder(dragItem.current, dragOverItem.current));
|
||||
dragItem.current = null;
|
||||
dragOverItem.current = null;
|
||||
}, [dispatch, dragItem, dragOverItem]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UploadProgress active={active} progress={progress} isProcessing={isProcessing} />
|
||||
|
||||
{mediaIds.size > 0 && (
|
||||
<div className='compose-form__uploads'>
|
||||
{mediaIds.map(id => (
|
||||
<Upload
|
||||
key={id}
|
||||
id={id}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!mediaIds.isEmpty() && <SensitiveButton />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import type { List } from 'immutable';
|
||||
|
||||
import type {
|
||||
DragStartEvent,
|
||||
DragEndEvent,
|
||||
UniqueIdentifier,
|
||||
Announcements,
|
||||
ScreenReaderInstructions,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
|
||||
import { changeMediaOrder } from 'flavours/glitch/actions/compose';
|
||||
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
import { SensitiveButton } from './sensitive_button';
|
||||
import { Upload } from './upload';
|
||||
import { UploadProgress } from './upload_progress';
|
||||
|
||||
const messages = defineMessages({
|
||||
screenReaderInstructions: {
|
||||
id: 'upload_form.drag_and_drop.instructions',
|
||||
defaultMessage:
|
||||
'To pick up a media attachment, press space or enter. While dragging, use the arrow keys to move the media attachment in any given direction. Press space or enter again to drop the media attachment in its new position, or press escape to cancel.',
|
||||
},
|
||||
onDragStart: {
|
||||
id: 'upload_form.drag_and_drop.on_drag_start',
|
||||
defaultMessage: 'Picked up media attachment {item}.',
|
||||
},
|
||||
onDragOver: {
|
||||
id: 'upload_form.drag_and_drop.on_drag_over',
|
||||
defaultMessage: 'Media attachment {item} was moved.',
|
||||
},
|
||||
onDragEnd: {
|
||||
id: 'upload_form.drag_and_drop.on_drag_end',
|
||||
defaultMessage: 'Media attachment {item} was dropped.',
|
||||
},
|
||||
onDragCancel: {
|
||||
id: 'upload_form.drag_and_drop.on_drag_cancel',
|
||||
defaultMessage:
|
||||
'Dragging was cancelled. Media attachment {item} was dropped.',
|
||||
},
|
||||
});
|
||||
|
||||
export const UploadForm: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const mediaIds = useAppSelector(
|
||||
(state) =>
|
||||
state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
|
||||
.get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
.map((item: MediaAttachment) => item.get('id')) as List<string>, // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
const active = useAppSelector(
|
||||
(state) => state.compose.get('is_uploading') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
const progress = useAppSelector(
|
||||
(state) => state.compose.get('progress') as number, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
const isProcessing = useAppSelector(
|
||||
(state) => state.compose.get('is_processing') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 5,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: DragStartEvent) => {
|
||||
const { active } = e;
|
||||
|
||||
setActiveId(active.id);
|
||||
},
|
||||
[setActiveId],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(e: DragEndEvent) => {
|
||||
const { active, over } = e;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
dispatch(changeMediaOrder(active.id, over.id));
|
||||
}
|
||||
|
||||
setActiveId(null);
|
||||
},
|
||||
[dispatch, setActiveId],
|
||||
);
|
||||
|
||||
const accessibility: {
|
||||
screenReaderInstructions: ScreenReaderInstructions;
|
||||
announcements: Announcements;
|
||||
} = useMemo(
|
||||
() => ({
|
||||
screenReaderInstructions: {
|
||||
draggable: intl.formatMessage(messages.screenReaderInstructions),
|
||||
},
|
||||
|
||||
announcements: {
|
||||
onDragStart({ active }) {
|
||||
return intl.formatMessage(messages.onDragStart, { item: active.id });
|
||||
},
|
||||
|
||||
onDragOver({ active }) {
|
||||
return intl.formatMessage(messages.onDragOver, { item: active.id });
|
||||
},
|
||||
|
||||
onDragEnd({ active }) {
|
||||
return intl.formatMessage(messages.onDragEnd, { item: active.id });
|
||||
},
|
||||
|
||||
onDragCancel({ active }) {
|
||||
return intl.formatMessage(messages.onDragCancel, { item: active.id });
|
||||
},
|
||||
},
|
||||
}),
|
||||
[intl],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UploadProgress
|
||||
active={active}
|
||||
progress={progress}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
|
||||
{mediaIds.size > 0 && (
|
||||
<div
|
||||
className={`compose-form__uploads media-gallery media-gallery--layout-${mediaIds.size}`}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
accessibility={accessibility}
|
||||
>
|
||||
<SortableContext
|
||||
items={mediaIds.toArray()}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
{mediaIds.map((id, idx) => (
|
||||
<Upload
|
||||
key={id}
|
||||
id={id}
|
||||
dragging={id === activeId}
|
||||
tall={mediaIds.size < 3 || (mediaIds.size === 3 && idx === 0)}
|
||||
wide={mediaIds.size === 1}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId ? <Upload id={activeId as string} overlay /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!mediaIds.isEmpty() && <SensitiveButton />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChangeEventHandler } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
@@ -26,6 +26,8 @@ import { RadioButton } from 'flavours/glitch/components/radio_button';
|
||||
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import { useSearchParam } from '../../hooks/useSearchParam';
|
||||
|
||||
import { AccountCard } from './components/account_card';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -50,18 +52,19 @@ export const Directory: React.FC<{
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [state, setState] = useState<{
|
||||
order: string | null;
|
||||
local: boolean | null;
|
||||
}>({
|
||||
order: null,
|
||||
local: null,
|
||||
});
|
||||
|
||||
const column = useRef<Column>(null);
|
||||
|
||||
const order = state.order ?? params?.order ?? 'active';
|
||||
const local = state.local ?? params?.local ?? false;
|
||||
const [orderParam, setOrderParam] = useSearchParam('order');
|
||||
const [localParam, setLocalParam] = useSearchParam('local');
|
||||
|
||||
let localParamBool: boolean | undefined;
|
||||
|
||||
if (localParam === 'false') {
|
||||
localParamBool = false;
|
||||
}
|
||||
|
||||
const order = orderParam ?? params?.order ?? 'active';
|
||||
const local = localParamBool ?? params?.local ?? true;
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
if (columnId) {
|
||||
@@ -104,10 +107,10 @@ export const Directory: React.FC<{
|
||||
if (columnId) {
|
||||
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
|
||||
} else {
|
||||
setState((s) => ({ order: e.target.value, local: s.local }));
|
||||
setOrderParam(e.target.value);
|
||||
}
|
||||
},
|
||||
[dispatch, columnId],
|
||||
[dispatch, columnId, setOrderParam],
|
||||
);
|
||||
|
||||
const handleChangeLocal = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||
@@ -116,11 +119,13 @@ export const Directory: React.FC<{
|
||||
dispatch(
|
||||
changeColumnParams(columnId, ['local'], e.target.value === '1'),
|
||||
);
|
||||
} else if (e.target.value === '1') {
|
||||
setLocalParam('true');
|
||||
} else {
|
||||
setState((s) => ({ local: e.target.value === '1', order: s.order }));
|
||||
setLocalParam('false');
|
||||
}
|
||||
},
|
||||
[dispatch, columnId],
|
||||
[dispatch, columnId, setLocalParam],
|
||||
);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export const FilteredNotificationsIconButton: React.FC<{
|
||||
history.push('/notifications/requests');
|
||||
}, [history]);
|
||||
|
||||
if (policy === null || policy.summary.pending_notifications_count === 0) {
|
||||
if (policy === null || policy.summary.pending_requests_count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export const FilteredNotificationsBanner: React.FC = () => {
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
if (policy === null || policy.summary.pending_notifications_count === 0) {
|
||||
if (policy === null || policy.summary.pending_requests_count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ import type { List as ImmutableList, RecordOf } from 'immutable';
|
||||
|
||||
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
|
||||
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
|
||||
import { toggleStatusSpoilers } from 'flavours/glitch/actions/statuses';
|
||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||
import { ContentWarning } from 'flavours/glitch/components/content_warning';
|
||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import type { Status } from 'flavours/glitch/models/status';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
import { EmbeddedStatusContent } from './embedded_status_content';
|
||||
|
||||
@@ -23,6 +25,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
const clickCoordinatesRef = useRef<[number, number] | null>();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const status = useAppSelector(
|
||||
(state) => state.statuses.get(statusId) as Status | undefined,
|
||||
@@ -96,15 +99,21 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const handleContentWarningClick = useCallback(() => {
|
||||
dispatch(toggleStatusSpoilers(statusId));
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Assign status attributes to variables with a forced type, as status is not yet properly typed
|
||||
const contentHtml = status.get('contentHtml') as string;
|
||||
const contentWarning = status.get('spoilerHtml') as string;
|
||||
const poll = status.get('poll');
|
||||
const language = status.get('language') as string;
|
||||
const mentions = status.get('mentions') as ImmutableList<Mention>;
|
||||
const expanded = !status.get('hidden') || !contentWarning;
|
||||
const mediaAttachmentsSize = (
|
||||
status.get('media_attachments') as ImmutableList<unknown>
|
||||
).size;
|
||||
@@ -124,14 +133,24 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
|
||||
<EmbeddedStatusContent
|
||||
className='notification-group__embedded-status__content reply-indicator__content translate'
|
||||
content={contentHtml}
|
||||
language={language}
|
||||
mentions={mentions}
|
||||
/>
|
||||
{contentWarning && (
|
||||
<ContentWarning
|
||||
text={contentWarning}
|
||||
onClick={handleContentWarningClick}
|
||||
expanded={expanded}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(poll || mediaAttachmentsSize > 0) && (
|
||||
{(!contentWarning || expanded) && (
|
||||
<EmbeddedStatusContent
|
||||
className='notification-group__embedded-status__content reply-indicator__content translate'
|
||||
content={contentHtml}
|
||||
language={language}
|
||||
mentions={mentions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{expanded && (poll || mediaAttachmentsSize > 0) && (
|
||||
<div className='notification-group__embedded-status__attachments reply-indicator__attachments'>
|
||||
{!!poll && (
|
||||
<>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Permalink } from 'flavours/glitch/components/permalink';
|
||||
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
||||
import { useAppHistory } from 'flavours/glitch/components/router';
|
||||
import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon';
|
||||
import PollContainer from 'flavours/glitch/containers/poll_container';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
@@ -194,6 +195,28 @@ export const DetailedStatus: React.FC<{
|
||||
)
|
||||
) {
|
||||
media.push(<AttachmentList media={status.get('media_attachments')} />);
|
||||
} else if (
|
||||
['image', 'gifv'].includes(
|
||||
status.getIn(['media_attachments', 0, 'type']) as string,
|
||||
) ||
|
||||
status.get('media_attachments').size > 1
|
||||
) {
|
||||
media.push(
|
||||
<MediaGallery
|
||||
standalone
|
||||
sensitive={status.get('sensitive')}
|
||||
media={status.get('media_attachments')}
|
||||
lang={language}
|
||||
height={300}
|
||||
letterbox={letterboxMedia}
|
||||
fullwidth={fullwidthMedia}
|
||||
hidden={!expanded}
|
||||
onOpenMedia={onOpenMedia}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
/>,
|
||||
);
|
||||
mediaIcons.push('picture-o');
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
const description =
|
||||
@@ -236,6 +259,7 @@ export const DetailedStatus: React.FC<{
|
||||
src={attachment.get('url')}
|
||||
alt={description}
|
||||
lang={language}
|
||||
inline
|
||||
width={300}
|
||||
height={150}
|
||||
onOpenVideo={handleOpenVideo}
|
||||
@@ -248,23 +272,6 @@ export const DetailedStatus: React.FC<{
|
||||
/>,
|
||||
);
|
||||
mediaIcons.push('video-camera');
|
||||
} else {
|
||||
media.push(
|
||||
<MediaGallery
|
||||
standalone
|
||||
sensitive={status.get('sensitive')}
|
||||
media={status.get('media_attachments')}
|
||||
lang={language}
|
||||
height={300}
|
||||
letterbox={letterboxMedia}
|
||||
fullwidth={fullwidthMedia}
|
||||
hidden={!expanded}
|
||||
onOpenMedia={onOpenMedia}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
/>,
|
||||
);
|
||||
mediaIcons.push('picture-o');
|
||||
}
|
||||
} else if (status.get('spoiler_text').length === 0) {
|
||||
media.push(
|
||||
@@ -277,6 +284,17 @@ export const DetailedStatus: React.FC<{
|
||||
mediaIcons.push('link');
|
||||
}
|
||||
|
||||
if (status.get('poll')) {
|
||||
contentMedia.push(
|
||||
<PollContainer
|
||||
pollId={status.get('poll')}
|
||||
// @ts-expect-error -- Poll/PollContainer is not typed yet
|
||||
lang={status.get('language')}
|
||||
/>,
|
||||
);
|
||||
contentMediaIcons.push('tasks');
|
||||
}
|
||||
|
||||
if (status.get('application')) {
|
||||
applicationLink = (
|
||||
<>
|
||||
|
||||
@@ -99,7 +99,7 @@ export const BlockModal = ({ accountId, acct }) => {
|
||||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
|
||||
<Button onClick={handleClick} autoFocus>
|
||||
<Button onClick={handleClick} dangerous autoFocus>
|
||||
<FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,11 @@ import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useRouteMatch, NavLink } from 'react-router-dom';
|
||||
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
|
||||
const ColumnLink = ({ icon, activeIcon, iconComponent, activeIconComponent, text, to, onClick, href, method, badge, transparent, ...other }) => {
|
||||
const ColumnLink = ({ icon, activeIcon, iconComponent, activeIconComponent, text, to, onClick, href, method, badge, transparent, optional, ...other }) => {
|
||||
const match = useRouteMatch(to);
|
||||
const className = classNames('column-link', { 'column-link--transparent': transparent });
|
||||
const className = classNames('column-link', { 'column-link--transparent': transparent, 'column-link--optional': optional });
|
||||
const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null;
|
||||
const iconElement = (typeof icon === 'string' || iconComponent) ? <Icon id={icon} icon={iconComponent} className='column-link__icon' /> : icon;
|
||||
const activeIconElement = activeIcon ?? (activeIconComponent ? <Icon id={icon} icon={activeIconComponent} className='column-link__icon' /> : iconElement);
|
||||
@@ -58,6 +58,7 @@ ColumnLink.propTypes = {
|
||||
method: PropTypes.string,
|
||||
badge: PropTypes.node,
|
||||
transparent: PropTypes.bool,
|
||||
optional: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ColumnLink;
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Children, cloneElement, useCallback } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
|
||||
import { scrollRight } from '../../../scroll';
|
||||
import BundleContainer from '../containers/bundle_container';
|
||||
import {
|
||||
@@ -65,17 +63,13 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
// Corresponds to (max-width: $no-gap-breakpoint - 1px) in SCSS
|
||||
mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1206px)');
|
||||
mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1174px)');
|
||||
|
||||
state = {
|
||||
renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.singleColumn) {
|
||||
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
|
||||
}
|
||||
|
||||
if (this.mediaQuery) {
|
||||
if (this.mediaQuery.addEventListener) {
|
||||
this.mediaQuery.addEventListener('change', this.handleLayoutChange);
|
||||
@@ -88,23 +82,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||
this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
|
||||
}
|
||||
|
||||
UNSAFE_componentWillUpdate(nextProps) {
|
||||
if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
|
||||
this.node.removeEventListener('wheel', this.handleWheel);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
|
||||
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (!this.props.singleColumn) {
|
||||
this.node.removeEventListener('wheel', this.handleWheel);
|
||||
}
|
||||
|
||||
if (this.mediaQuery) {
|
||||
if (this.mediaQuery.removeEventListener) {
|
||||
this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
|
||||
@@ -117,7 +95,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||
handleChildrenContentChange() {
|
||||
if (!this.props.singleColumn) {
|
||||
const modifier = this.isRtlLayout ? -1 : 1;
|
||||
this._interruptScrollAnimation = scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier);
|
||||
scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,14 +103,6 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||
this.setState({ renderComposePanel: !e.matches });
|
||||
};
|
||||
|
||||
handleWheel = () => {
|
||||
if (typeof this._interruptScrollAnimation !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
this._interruptScrollAnimation();
|
||||
};
|
||||
|
||||
setRef = (node) => {
|
||||
this.node = node;
|
||||
};
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import CampaignIcon from '@/material-icons/400-24px/campaign.svg?react';
|
||||
import DomainDisabledIcon from '@/material-icons/400-24px/domain_disabled.svg?react';
|
||||
import HistoryIcon from '@/material-icons/400-24px/history.svg?react';
|
||||
import PersonRemoveIcon from '@/material-icons/400-24px/person_remove.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import { blockAccount } from 'flavours/glitch/actions/accounts';
|
||||
import { blockDomain } from 'flavours/glitch/actions/domain_blocks';
|
||||
import { closeModal } from 'flavours/glitch/actions/modal';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
|
||||
export const DomainBlockModal = ({ domain, accountId, acct }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
dispatch(blockDomain(domain));
|
||||
}, [dispatch, domain]);
|
||||
|
||||
const handleSecondaryClick = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
dispatch(blockAccount(accountId));
|
||||
}, [dispatch, accountId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal safety-action-modal'>
|
||||
<div className='safety-action-modal__top'>
|
||||
<div className='safety-action-modal__header'>
|
||||
<div className='safety-action-modal__header__icon'>
|
||||
<Icon icon={DomainDisabledIcon} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1><FormattedMessage id='domain_block_modal.title' defaultMessage='Block domain?' /></h1>
|
||||
<div>{domain}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bullet-points'>
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={CampaignIcon} /></div>
|
||||
<div><FormattedMessage id='domain_block_modal.they_wont_know' defaultMessage="They won't know they've been blocked." /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={VisibilityOffIcon} /></div>
|
||||
<div><FormattedMessage id='domain_block_modal.you_wont_see_posts' defaultMessage="You won't see posts or notifications from users on this server." /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={PersonRemoveIcon} /></div>
|
||||
<div><FormattedMessage id='domain_block_modal.you_will_lose_followers' defaultMessage='All your followers from this server will be removed.' /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={ReplyIcon} /></div>
|
||||
<div><FormattedMessage id='domain_block_modal.they_cant_follow' defaultMessage='Nobody from this server can follow you.' /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={HistoryIcon} /></div>
|
||||
<div><FormattedMessage id='domain_block_modal.they_can_interact_with_old_posts' defaultMessage='People from this server can interact with your old posts.' /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bottom'>
|
||||
<div className='safety-action-modal__actions'>
|
||||
<Button onClick={handleSecondaryClick} secondary>
|
||||
<FormattedMessage id='domain_block_modal.block_account_instead' defaultMessage='Block @{name} instead' values={{ name: acct.split('@')[0] }} />
|
||||
</Button>
|
||||
|
||||
<div className='spacer' />
|
||||
|
||||
<button onClick={handleCancel} className='link-button'>
|
||||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
|
||||
<Button onClick={handleClick} autoFocus>
|
||||
<FormattedMessage id='domain_block_modal.block' defaultMessage='Block server' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DomainBlockModal.propTypes = {
|
||||
domain: PropTypes.string.isRequired,
|
||||
accountId: PropTypes.string.isRequired,
|
||||
acct: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default DomainBlockModal;
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import CampaignIcon from '@/material-icons/400-24px/campaign.svg?react';
|
||||
import DomainDisabledIcon from '@/material-icons/400-24px/domain_disabled.svg?react';
|
||||
import HistoryIcon from '@/material-icons/400-24px/history.svg?react';
|
||||
import PersonRemoveIcon from '@/material-icons/400-24px/person_remove.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import { blockAccount } from 'flavours/glitch/actions/accounts';
|
||||
import { blockDomain } from 'flavours/glitch/actions/domain_blocks';
|
||||
import { closeModal } from 'flavours/glitch/actions/modal';
|
||||
import { apiRequest } from 'flavours/glitch/api';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import { useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
interface DomainBlockPreviewResponse {
|
||||
following_count: number;
|
||||
followers_count: number;
|
||||
}
|
||||
|
||||
export const DomainBlockModal: React.FC<{
|
||||
domain: string;
|
||||
accountId: string;
|
||||
acct: string;
|
||||
}> = ({ domain, accountId, acct }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [preview, setPreview] = useState<
|
||||
DomainBlockPreviewResponse | 'error' | null
|
||||
>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (loading) {
|
||||
return; // Prevent destructive action before the preview finishes loading or times out
|
||||
}
|
||||
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
dispatch(blockDomain(domain));
|
||||
}, [dispatch, loading, domain]);
|
||||
|
||||
const handleSecondaryClick = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
dispatch(blockAccount(accountId));
|
||||
}, [dispatch, accountId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
apiRequest<DomainBlockPreviewResponse>('GET', 'v1/domain_blocks/preview', {
|
||||
params: { domain },
|
||||
timeout: 5000,
|
||||
})
|
||||
.then((data) => {
|
||||
setPreview(data);
|
||||
setLoading(false);
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setPreview('error');
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setPreview, setLoading, domain]);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal safety-action-modal' aria-live='polite'>
|
||||
<div className='safety-action-modal__top'>
|
||||
<div className='safety-action-modal__header'>
|
||||
<div className='safety-action-modal__header__icon'>
|
||||
<Icon id='' icon={DomainDisabledIcon} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.title'
|
||||
defaultMessage='Block domain?'
|
||||
/>
|
||||
</h1>
|
||||
<div>{domain}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bullet-points'>
|
||||
{preview &&
|
||||
preview !== 'error' &&
|
||||
preview.followers_count + preview.following_count > 0 && (
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'>
|
||||
<Icon id='' icon={PersonRemoveIcon} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.you_will_lose_num_followers'
|
||||
defaultMessage='You will lose {followersCount, plural, one {{followersCountDisplay} follower} other {{followersCountDisplay} followers}} and {followingCount, plural, one {{followingCountDisplay} person you follow} other {{followingCountDisplay} people you follow}}.'
|
||||
values={{
|
||||
followersCount: preview.followers_count,
|
||||
followersCountDisplay: (
|
||||
<ShortNumber value={preview.followers_count} />
|
||||
),
|
||||
followingCount: preview.following_count,
|
||||
followingCountDisplay: (
|
||||
<ShortNumber value={preview.following_count} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview === 'error' && (
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'>
|
||||
<Icon id='' icon={PersonRemoveIcon} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.you_will_lose_relationships'
|
||||
defaultMessage='You will lose all followers and people you follow from this server.'
|
||||
/>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='safety-action-modal__bullet-points--deemphasized'>
|
||||
<div className='safety-action-modal__bullet-points__icon'>
|
||||
<Icon id='' icon={CampaignIcon} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.they_wont_know'
|
||||
defaultMessage="They won't know they've been blocked."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bullet-points--deemphasized'>
|
||||
<div className='safety-action-modal__bullet-points__icon'>
|
||||
<Icon id='' icon={VisibilityOffIcon} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.you_wont_see_posts'
|
||||
defaultMessage="You won't see posts or notifications from users on this server."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bullet-points--deemphasized'>
|
||||
<div className='safety-action-modal__bullet-points__icon'>
|
||||
<Icon id='' icon={ReplyIcon} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.they_cant_follow'
|
||||
defaultMessage='Nobody from this server can follow you.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bullet-points--deemphasized'>
|
||||
<div className='safety-action-modal__bullet-points__icon'>
|
||||
<Icon id='' icon={HistoryIcon} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.they_can_interact_with_old_posts'
|
||||
defaultMessage='People from this server can interact with your old posts.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='safety-action-modal__bottom'>
|
||||
<div className='safety-action-modal__actions'>
|
||||
<Button onClick={handleSecondaryClick} secondary>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.block_account_instead'
|
||||
defaultMessage='Block @{name} instead'
|
||||
values={{ name: acct.split('@')[0] }}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<div className='spacer' />
|
||||
|
||||
<button onClick={handleCancel} className='link-button'>
|
||||
<FormattedMessage
|
||||
id='confirmation_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Button onClick={handleClick} dangerous aria-busy={loading}>
|
||||
{loading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.block'
|
||||
defaultMessage='Block server'
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default DomainBlockModal;
|
||||
@@ -17,7 +17,7 @@ export default class ImageLoader extends PureComponent {
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
onClick: PropTypes.func,
|
||||
zoomButtonHidden: PropTypes.bool,
|
||||
zoomedIn: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -134,7 +134,7 @@ export default class ImageLoader extends PureComponent {
|
||||
};
|
||||
|
||||
render () {
|
||||
const { alt, lang, src, width, height, onClick } = this.props;
|
||||
const { alt, lang, src, width, height, onClick, zoomedIn } = this.props;
|
||||
const { loading } = this.state;
|
||||
|
||||
const className = classNames('image-loader', {
|
||||
@@ -149,6 +149,7 @@ export default class ImageLoader extends PureComponent {
|
||||
<div className='loading-bar__container' style={{ width: this.state.width || width }}>
|
||||
<LoadingBar className='loading-bar' loading={1} />
|
||||
</div>
|
||||
|
||||
<canvas
|
||||
className='image-loader__preview-canvas'
|
||||
ref={this.setCanvasRef}
|
||||
@@ -164,7 +165,7 @@ export default class ImageLoader extends PureComponent {
|
||||
onClick={onClick}
|
||||
width={width}
|
||||
height={height}
|
||||
zoomButtonHidden={this.props.zoomButtonHidden}
|
||||
zoomedIn={zoomedIn}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ import ReactSwipeableViews from 'react-swipeable-views';
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import FitScreenIcon from '@/material-icons/400-24px/fit_screen.svg?react';
|
||||
import ActualSizeIcon from '@/svg-icons/actual_size.svg?react';
|
||||
import { getAverageFromBlurhash } from 'flavours/glitch/blurhash';
|
||||
import { GIFV } from 'flavours/glitch/components/gifv';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
@@ -26,6 +28,8 @@ const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||
zoomIn: { id: 'lightbox.zoom_in', defaultMessage: 'Zoom to actual size' },
|
||||
zoomOut: { id: 'lightbox.zoom_out', defaultMessage: 'Zoom to fit' },
|
||||
});
|
||||
|
||||
class MediaModal extends ImmutablePureComponent {
|
||||
@@ -46,30 +50,39 @@ class MediaModal extends ImmutablePureComponent {
|
||||
state = {
|
||||
index: null,
|
||||
navigationHidden: false,
|
||||
zoomButtonHidden: false,
|
||||
zoomedIn: false,
|
||||
};
|
||||
|
||||
handleZoomClick = () => {
|
||||
this.setState(prevState => ({
|
||||
zoomedIn: !prevState.zoomedIn,
|
||||
}));
|
||||
};
|
||||
|
||||
handleSwipe = (index) => {
|
||||
this.setState({ index: index % this.props.media.size });
|
||||
this.setState({
|
||||
index: index % this.props.media.size,
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
handleTransitionEnd = () => {
|
||||
this.setState({
|
||||
zoomButtonHidden: false,
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
handleNextClick = () => {
|
||||
this.setState({
|
||||
index: (this.getIndex() + 1) % this.props.media.size,
|
||||
zoomButtonHidden: true,
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
handlePrevClick = () => {
|
||||
this.setState({
|
||||
index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
|
||||
zoomButtonHidden: true,
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -78,7 +91,7 @@ class MediaModal extends ImmutablePureComponent {
|
||||
|
||||
this.setState({
|
||||
index: index % this.props.media.size,
|
||||
zoomButtonHidden: true,
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -130,15 +143,22 @@ class MediaModal extends ImmutablePureComponent {
|
||||
return this.state.index !== null ? this.state.index : this.props.index;
|
||||
}
|
||||
|
||||
toggleNavigation = () => {
|
||||
handleToggleNavigation = () => {
|
||||
this.setState(prevState => ({
|
||||
navigationHidden: !prevState.navigationHidden,
|
||||
}));
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.setState({
|
||||
viewportWidth: c?.clientWidth,
|
||||
viewportHeight: c?.clientHeight,
|
||||
});
|
||||
};
|
||||
|
||||
render () {
|
||||
const { media, statusId, lang, intl, onClose } = this.props;
|
||||
const { navigationHidden } = this.state;
|
||||
const { navigationHidden, zoomedIn, viewportWidth, viewportHeight } = this.state;
|
||||
|
||||
const index = this.getIndex();
|
||||
|
||||
@@ -160,8 +180,8 @@ class MediaModal extends ImmutablePureComponent {
|
||||
alt={description}
|
||||
lang={lang}
|
||||
key={image.get('url')}
|
||||
onClick={this.toggleNavigation}
|
||||
zoomButtonHidden={this.state.zoomButtonHidden}
|
||||
onClick={this.handleToggleNavigation}
|
||||
zoomedIn={zoomedIn}
|
||||
/>
|
||||
);
|
||||
} else if (image.get('type') === 'video') {
|
||||
@@ -229,9 +249,12 @@ class MediaModal extends ImmutablePureComponent {
|
||||
));
|
||||
}
|
||||
|
||||
const currentMedia = media.get(index);
|
||||
const zoomable = currentMedia.get('type') === 'image' && (currentMedia.getIn(['meta', 'original', 'width']) > viewportWidth || currentMedia.getIn(['meta', 'original', 'height']) > viewportHeight);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal media-modal'>
|
||||
<div className='media-modal__closer' role='presentation' onClick={onClose} >
|
||||
<div className='modal-root__modal media-modal' ref={this.setRef}>
|
||||
<div className='media-modal__closer' role='presentation' onClick={onClose}>
|
||||
<ReactSwipeableViews
|
||||
style={swipeableViewsStyle}
|
||||
containerStyle={containerStyle}
|
||||
@@ -245,7 +268,10 @@ class MediaModal extends ImmutablePureComponent {
|
||||
</div>
|
||||
|
||||
<div className={navigationClassName}>
|
||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={40} />
|
||||
<div className='media-modal__buttons'>
|
||||
{zoomable && <IconButton title={intl.formatMessage(zoomedIn ? messages.zoomOut : messages.zoomIn)} iconComponent={zoomedIn ? FitScreenIcon : ActualSizeIcon} onClick={this.handleZoomClick} />}
|
||||
<IconButton title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} />
|
||||
</div>
|
||||
|
||||
{leftNav}
|
||||
{rightNav}
|
||||
|
||||
@@ -122,14 +122,17 @@ class NavigationPanel extends Component {
|
||||
|
||||
let banner = undefined;
|
||||
|
||||
if(transientSingleColumn)
|
||||
banner = (<div className='switch-to-advanced'>
|
||||
{intl.formatMessage(messages.openedInClassicInterface)}
|
||||
{" "}
|
||||
<a href={`/deck${location.pathname}`} className='switch-to-advanced__toggle'>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
</div>);
|
||||
if (transientSingleColumn) {
|
||||
banner = (
|
||||
<div className='switch-to-advanced'>
|
||||
{intl.formatMessage(messages.openedInClassicInterface)}
|
||||
{" "}
|
||||
<a href={`/deck${location.pathname}`} className='switch-to-advanced__toggle'>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='navigation-panel'>
|
||||
@@ -139,55 +142,59 @@ class NavigationPanel extends Component {
|
||||
</div>
|
||||
}
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
<ColumnLink transparent to='/home' icon='home' iconComponent={HomeIcon} activeIconComponent={HomeActiveIcon} text={intl.formatMessage(messages.home)} />
|
||||
<NotificationsLink />
|
||||
<FollowRequestsLink />
|
||||
</>
|
||||
)}
|
||||
<div className='navigation-panel__menu'>
|
||||
{signedIn && (
|
||||
<>
|
||||
<ColumnLink transparent to='/home' icon='home' iconComponent={HomeIcon} activeIconComponent={HomeActiveIcon} text={intl.formatMessage(messages.home)} />
|
||||
<NotificationsLink />
|
||||
<FollowRequestsLink />
|
||||
</>
|
||||
)}
|
||||
|
||||
{trendsEnabled ? (
|
||||
<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)} />
|
||||
)}
|
||||
{trendsEnabled ? (
|
||||
<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)} />
|
||||
)}
|
||||
|
||||
{(signedIn || timelinePreview) && (
|
||||
<ColumnLink transparent to='/public/local' isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
|
||||
)}
|
||||
{(signedIn || timelinePreview) && (
|
||||
<ColumnLink transparent to='/public/local' isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
|
||||
)}
|
||||
|
||||
{!signedIn && (
|
||||
<div className='navigation-panel__sign-in-banner'>
|
||||
{!signedIn && (
|
||||
<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 />
|
||||
{ disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner /> }
|
||||
<ColumnLink transparent to='/about' icon='ellipsis-h' iconComponent={MoreHorizIcon} text={intl.formatMessage(messages.about)} />
|
||||
</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 className='flex-spacer' />
|
||||
|
||||
<NavigationPortal />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import PropTypes from 'prop-types';
|
||||
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 MAX_SCALE = 4;
|
||||
const NAV_BAR_HEIGHT = 66;
|
||||
@@ -104,8 +93,7 @@ class ZoomableImage extends PureComponent {
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
onClick: PropTypes.func,
|
||||
zoomButtonHidden: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
zoomedIn: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -131,8 +119,6 @@ class ZoomableImage extends PureComponent {
|
||||
translateX: null,
|
||||
translateY: null,
|
||||
},
|
||||
zoomState: 'expand', // 'expand' 'compress'
|
||||
navigationHidden: false,
|
||||
dragPosition: { top: 0, left: 0, x: 0, y: 0 },
|
||||
dragged: false,
|
||||
lockScroll: { x: 0, y: 0 },
|
||||
@@ -169,35 +155,20 @@ class ZoomableImage extends PureComponent {
|
||||
this.container.addEventListener('DOMMouseScroll', handler);
|
||||
this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
|
||||
|
||||
this.initZoomMatrix();
|
||||
this._initZoomMatrix();
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.removeEventListeners();
|
||||
this._removeEventListeners();
|
||||
}
|
||||
|
||||
componentDidUpdate () {
|
||||
this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' });
|
||||
|
||||
if (this.state.scale === MIN_SCALE) {
|
||||
this.container.style.removeProperty('cursor');
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.zoomedIn !== this.props.zoomedIn) {
|
||||
this._toggleZoom();
|
||||
}
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps () {
|
||||
// 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 () {
|
||||
_removeEventListeners () {
|
||||
this.removers.forEach(listeners => listeners());
|
||||
this.removers = [];
|
||||
}
|
||||
@@ -220,9 +191,6 @@ class ZoomableImage extends PureComponent {
|
||||
};
|
||||
|
||||
mouseDownHandler = e => {
|
||||
this.container.style.cursor = 'grabbing';
|
||||
this.container.style.userSelect = 'none';
|
||||
|
||||
this.setState({ dragPosition: {
|
||||
left: this.container.scrollLeft,
|
||||
top: this.container.scrollTop,
|
||||
@@ -246,9 +214,6 @@ class ZoomableImage extends PureComponent {
|
||||
};
|
||||
|
||||
mouseUpHandler = () => {
|
||||
this.container.style.cursor = 'grab';
|
||||
this.container.style.removeProperty('user-select');
|
||||
|
||||
this.image.removeEventListener('mousemove', this.mouseMoveHandler);
|
||||
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 scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
|
||||
|
||||
this.zoom(scale, midpoint);
|
||||
this._zoom(scale, midpoint);
|
||||
|
||||
this.lastMidpoint = midpoint;
|
||||
this.lastDistance = distance;
|
||||
};
|
||||
|
||||
zoom(nextScale, midpoint) {
|
||||
_zoom(nextScale, midpoint) {
|
||||
const { scale, zoomMatrix } = this.state;
|
||||
const { scrollLeft, scrollTop } = this.container;
|
||||
|
||||
@@ -318,14 +283,13 @@ class ZoomableImage extends PureComponent {
|
||||
if (dragged) return;
|
||||
const handler = this.props.onClick;
|
||||
if (handler) handler();
|
||||
this.setState({ navigationHidden: !this.state.navigationHidden });
|
||||
};
|
||||
|
||||
handleMouseDown = e => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
initZoomMatrix = () => {
|
||||
_initZoomMatrix = () => {
|
||||
const { width, height } = this.props;
|
||||
const { clientWidth, clientHeight } = this.container;
|
||||
const { offsetWidth, offsetHeight } = this.image;
|
||||
@@ -357,10 +321,7 @@ class ZoomableImage extends PureComponent {
|
||||
});
|
||||
};
|
||||
|
||||
handleZoomClick = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
_toggleZoom () {
|
||||
const { scale, zoomMatrix } = this.state;
|
||||
|
||||
if ( scale >= zoomMatrix.rate ) {
|
||||
@@ -394,10 +355,7 @@ class ZoomableImage extends PureComponent {
|
||||
this.container.scrollTop = zoomMatrix.scrollTop;
|
||||
});
|
||||
}
|
||||
|
||||
this.container.style.cursor = 'grab';
|
||||
this.container.style.removeProperty('user-select');
|
||||
};
|
||||
}
|
||||
|
||||
setContainerRef = c => {
|
||||
this.container = c;
|
||||
@@ -408,52 +366,37 @@ class ZoomableImage extends PureComponent {
|
||||
};
|
||||
|
||||
render () {
|
||||
const { alt, lang, src, width, height, intl } = this.props;
|
||||
const { scale, lockTranslate } = this.state;
|
||||
const { alt, lang, src, width, height } = this.props;
|
||||
const { scale, lockTranslate, dragged } = this.state;
|
||||
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 zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand);
|
||||
const cursor = scale === MIN_SCALE ? null : (dragged ? 'grabbing' : 'grab');
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
className={`media-modal__zoom-button ${zoomButtonShouldHide}`}
|
||||
title={zoomButtonTitle}
|
||||
icon={this.state.zoomState}
|
||||
iconComponent={this.state.zoomState === 'compress' ? FullscreenExitIcon : RectangleIcon}
|
||||
onClick={this.handleZoomClick}
|
||||
size={40}
|
||||
<div
|
||||
className='zoomable-image'
|
||||
ref={this.setContainerRef}
|
||||
style={{ overflow, cursor, userSelect: 'none' }}
|
||||
>
|
||||
<img
|
||||
role='presentation'
|
||||
ref={this.setImageRef}
|
||||
alt={alt}
|
||||
title={alt}
|
||||
lang={lang}
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
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
|
||||
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>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(ZoomableImage);
|
||||
export default ZoomableImage;
|
||||
|
||||
@@ -196,7 +196,7 @@ class SwitchingColumnsArea extends PureComponent {
|
||||
{redirect}
|
||||
|
||||
{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 */}
|
||||
{!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}
|
||||
|
||||
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": "جلتش-سوك هو برنامج حر مفتوح المصدر متفرع عن ماستدون.",
|
||||
"account.disclaimer_full": "قد لا تعكِس المعلومات أدناه كامل الملف الشخصي للمستخدِم.",
|
||||
"account.follows": "يتابِع",
|
||||
"account.follows_you": "Follows you",
|
||||
"account.suspended_disclaimer_full": "تم تعليق هذا المستخدم من قبل المشرف.",
|
||||
"account.view_full_profile": "عرض الملف الشخصي كاملاً",
|
||||
"boost_modal.missing_description": "هذا المنشور يحتوي على وسائط بلا وصف",
|
||||
@@ -13,6 +14,7 @@
|
||||
"column_subheading.lists": "القوائم",
|
||||
"column_subheading.navigation": "التنقل",
|
||||
"community.column_settings.allow_local_only": "إظهار المنشورات المحلية فقط",
|
||||
"compose.attach.doodle": "الرسوم و التخمين",
|
||||
"compose.change_federation": "تغيير اعدادات الفيديرالية",
|
||||
"compose.content-type.change": "تغيير خيارات التنسيق المتقدمة",
|
||||
"compose.content-type.html": "HTML",
|
||||
@@ -26,13 +28,8 @@
|
||||
"confirmations.deprecated_settings.message": "تم استبدال بعض من الجهاز الخاص بالماستدون {preferences} الذي تستخدمه {app_settings} الخاص بجهاز ماستدون سيتم تجاوزه:",
|
||||
"confirmations.missing_media_description.confirm": "أرسل على أيّة حال",
|
||||
"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": "تجميع حسب المحادثة",
|
||||
"endorsed_accounts_editor.endorsed_accounts": "الحسابات المميزة",
|
||||
"favourite_modal.combo": "يُمكنك الضّغط على {combo} لتخطي هذا في المرة المُقبلة",
|
||||
"federation.federated.long": "السماح لهذا المنشور الوصول إلى خوادم أخرى",
|
||||
"federation.local_only.long": "منع هذا المنشور من الوصول إلى الخوادم الأخرى",
|
||||
"federation.local_only.short": "محلي فقط",
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"confirmation_modal.do_not_ask_again": "Příště se už neptat",
|
||||
"direct.group_by_conversations": "Seskupit do konverzací",
|
||||
"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.filter_regex": "Filtrovat podle regulárních výrazů",
|
||||
"home.column_settings.show_direct": "Zobrazit přímé zprávy",
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
"compose.content-type.plain": "Testun plaen",
|
||||
"confirmations.missing_media_description.confirm": "Anfon beth bynnag",
|
||||
"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.preferences": "Preferences"
|
||||
}
|
||||
|
||||
@@ -34,13 +34,9 @@
|
||||
"confirmations.missing_media_description.confirm": "Trotzdem absenden",
|
||||
"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.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",
|
||||
"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.short": "Föderiert",
|
||||
"federation.local_only.long": "Verhindere, dass dieser Post andere Server erreicht",
|
||||
@@ -128,6 +124,7 @@
|
||||
"settings.shared_settings_link": "Nutzereinstellungen",
|
||||
"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_published_toast": "Meldung beim Veröffentlichen/Speichern eines Beitrags anzeigen",
|
||||
"settings.show_reply_counter": "Schätzung der Antwortanzahl anzeigen",
|
||||
"settings.side_arm": "Sekundärer Toot-Knopf:",
|
||||
"settings.side_arm.none": "Nichts",
|
||||
@@ -147,12 +144,17 @@
|
||||
"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.",
|
||||
"status.collapse": "Einklappen",
|
||||
"status.filtered": "Gefiltert",
|
||||
"status.has_audio": "Hat angehängte Audiodateien",
|
||||
"status.has_pictures": "Hat angehängte Bilder",
|
||||
"status.has_preview_card": "Hat eine Vorschaukarte",
|
||||
"status.has_video": "Hat angehängte Videos",
|
||||
"status.hide": "Beitrag ausblenden",
|
||||
"status.in_reply_to": "Dieser Toot ist eine Antwort",
|
||||
"status.is_poll": "Dieser Toot ist eine Umfrage",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -22,10 +22,6 @@
|
||||
"confirmations.missing_media_description.confirm": "Sendi ĉiuokaze",
|
||||
"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.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",
|
||||
"navigation_bar.keyboard_shortcuts": "Fulmoklavoj",
|
||||
"notification_purge.btn_all": "Selekti ĉiujn",
|
||||
|
||||
@@ -34,13 +34,9 @@
|
||||
"confirmations.missing_media_description.confirm": "Enviar de todos modos",
|
||||
"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.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",
|
||||
"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.short": "Federado",
|
||||
"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_hint": "Expande las columnas para llenar mejor el espacio disponible.",
|
||||
"status.collapse": "Colapsar",
|
||||
"status.filtered": "Filtrado",
|
||||
"status.has_audio": "Contiene archivos de audio",
|
||||
"status.has_pictures": "Contiene imágenes adjuntas",
|
||||
"status.has_preview_card": "Contiene una tarjeta de vista previa adjunta",
|
||||
"status.has_video": "Contiene videos adjuntos",
|
||||
"status.hide": "Ocultar mensaje",
|
||||
"status.in_reply_to": "Esta publicación es una respuesta",
|
||||
"status.is_poll": "Esta publicación es una encuesta",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -31,13 +31,8 @@
|
||||
"confirmations.missing_media_description.confirm": "Enviar de todos modos",
|
||||
"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.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",
|
||||
"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.short": "Federado",
|
||||
"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.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.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",
|
||||
"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.short": "Federado",
|
||||
"federation.local_only.long": "Evitar que esta publicación llegue a otros servidores",
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
"compose.content-type.markdown": "مارکدون",
|
||||
"compose.content-type.plain": "متن ساده",
|
||||
"confirmations.missing_media_description.edit": "ویرایش رسانه",
|
||||
"confirmations.unfilter.author": "نویسنده",
|
||||
"confirmations.unfilter.confirm": "نمایش",
|
||||
"confirmations.unfilter.edit_filter": "ویرایش پالایه",
|
||||
"endorsed_accounts_editor.endorsed_accounts": "حسابهای پیشنهاد شده",
|
||||
"home.column_settings.advanced": "پیشرفته",
|
||||
"navigation_bar.app_settings": "تنظیمات کاره",
|
||||
|
||||
@@ -31,13 +31,8 @@
|
||||
"confirmations.missing_media_description.confirm": "Envoyer quand même",
|
||||
"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.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",
|
||||
"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.short": "Fédéré",
|
||||
"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.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.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",
|
||||
"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.short": "Fédéré",
|
||||
"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.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.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",
|
||||
"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.short": "Difederasi",
|
||||
"federation.local_only.long": "Cegah postingan ini menjangkau server lain",
|
||||
|
||||
@@ -26,13 +26,8 @@
|
||||
"confirmations.missing_media_description.confirm": "このまま投稿",
|
||||
"confirmations.missing_media_description.edit": "メディアを編集",
|
||||
"confirmations.missing_media_description.message": "少なくとも1つの画像に視覚障害者のための画像説明が付与されていません。すべての画像に対して説明を付与することを望みます。",
|
||||
"confirmations.unfilter.author": "筆者",
|
||||
"confirmations.unfilter.confirm": "見る",
|
||||
"confirmations.unfilter.edit_filter": "フィルターを編集",
|
||||
"confirmations.unfilter.filters": "適用されたフィルター",
|
||||
"direct.group_by_conversations": "会話でグループ化",
|
||||
"endorsed_accounts_editor.endorsed_accounts": "紹介しているユーザー",
|
||||
"favourite_modal.combo": "次からは {combo} を押せば、これをスキップできます。",
|
||||
"federation.federated.short": "連合",
|
||||
"federation.local_only.short": "ローカル限定",
|
||||
"home.column_settings.advanced": "高度",
|
||||
|
||||
@@ -34,13 +34,9 @@
|
||||
"confirmations.missing_media_description.confirm": "그냥 보내기",
|
||||
"confirmations.missing_media_description.edit": "미디어 편집",
|
||||
"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": "대화별로 묶기",
|
||||
"endorsed_accounts_editor.endorsed_accounts": "추천하는 계정들",
|
||||
"favourite_modal.combo": "다음엔 {combo}를 눌러 건너뛸 수 있습니다",
|
||||
"favourite_modal.favourite": "관심글로 지정할까요?",
|
||||
"federation.federated.long": "이 게시물이 다른 서버에 전달되는 것을 허용",
|
||||
"federation.federated.short": "연합됨",
|
||||
"federation.local_only.long": "이 게시물이 다른 서버에 전달되는 것을 막기",
|
||||
@@ -148,12 +144,17 @@
|
||||
"settings.wide_view": "넓은 뷰 (데스크탑 모드 전용)",
|
||||
"settings.wide_view_hint": "컬럼들을 늘려서 활용 가능한 공간을 사용합니다.",
|
||||
"status.collapse": "접기",
|
||||
"status.filtered": "걸러짐",
|
||||
"status.has_audio": "소리 파일이 첨부되어 있습니다",
|
||||
"status.has_pictures": "그림 파일이 첨부되어 있습니다",
|
||||
"status.has_preview_card": "미리보기 카드가 첨부되어 있습니다",
|
||||
"status.has_video": "영상이 첨부되어 있습니다",
|
||||
"status.hide": "게시물 숨기기",
|
||||
"status.in_reply_to": "이 글은 답글입니다",
|
||||
"status.is_poll": "이 글은 설문입니다",
|
||||
"status.local_only": "당신의 서버에서만 보입니다",
|
||||
"status.show_filter_reason": "그냥 표시하기",
|
||||
"status.show_less": "접기",
|
||||
"status.show_more": "더보기",
|
||||
"status.uncollapse": "펼치기"
|
||||
}
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
"confirmations.missing_media_description.confirm": "Toch verzenden",
|
||||
"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.unfilter.author": "Auteur",
|
||||
"confirmations.unfilter.confirm": "Weergeven",
|
||||
"confirmations.unfilter.edit_filter": "Filter bewerken",
|
||||
"direct.group_by_conversations": "Groeperen op gesprek",
|
||||
"endorsed_accounts_editor.endorsed_accounts": "Aanbevolen accounts",
|
||||
"home.column_settings.advanced": "Geavanceerd",
|
||||
|
||||
@@ -22,12 +22,8 @@
|
||||
"confirmations.missing_media_description.confirm": "Zignoruj i wyślij",
|
||||
"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.unfilter.author": "Autor",
|
||||
"confirmations.unfilter.confirm": "Pokaż",
|
||||
"confirmations.unfilter.edit_filter": "Edytuj filtr",
|
||||
"direct.group_by_conversations": "Grupuj rozmowami",
|
||||
"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.filter_regex": "Filtruj, używając wyrażeń regularnych",
|
||||
"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.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.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",
|
||||
"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.filter_regex": "Filtrar com uma expressão regular",
|
||||
"home.column_settings.show_direct": "Mostrar DMs",
|
||||
|
||||
@@ -20,13 +20,8 @@
|
||||
"confirmations.missing_media_description.confirm": "Lägg ut ändå",
|
||||
"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.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",
|
||||
"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\"",
|
||||
"home.column_settings.advanced": "Avancerat",
|
||||
"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.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.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",
|
||||
"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.auto_collapse": "Otomatik küçülme",
|
||||
"settings.auto_collapse_all": "Her şey",
|
||||
|
||||
@@ -22,13 +22,8 @@
|
||||
"confirmations.missing_media_description.confirm": "Все одно надіслати",
|
||||
"confirmations.missing_media_description.edit": "Редагувати медіа",
|
||||
"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": "Групування за розмовами",
|
||||
"endorsed_accounts_editor.endorsed_accounts": "Рекомендовані облікові записи",
|
||||
"favourite_modal.combo": "Ви можете натиснути {combo}, щоб пропустити це наступного разу",
|
||||
"firehose.column_settings.allow_local_only": "Відображати локальні повідомлення в \"Все\"",
|
||||
"home.column_settings.advanced": "Додатково",
|
||||
"home.column_settings.filter_regex": "Відфільтрувати за допомогою регулярних виразів",
|
||||
|
||||
@@ -34,13 +34,8 @@
|
||||
"confirmations.missing_media_description.confirm": "仍然发送",
|
||||
"confirmations.missing_media_description.edit": "编辑媒体",
|
||||
"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": "按对话分组",
|
||||
"endorsed_accounts_editor.endorsed_accounts": "精选账户",
|
||||
"favourite_modal.combo": "下次你可以按 {combo} 跳过这个",
|
||||
"federation.federated.long": "允许此嘟文到达其它服务器",
|
||||
"federation.federated.short": "联动",
|
||||
"federation.local_only.long": "阻止此嘟文到达其它服务器",
|
||||
@@ -148,12 +143,17 @@
|
||||
"settings.wide_view": "宽视图(仅限于桌面模式)",
|
||||
"settings.wide_view_hint": "拉伸列宽以更好地填充可用空间。",
|
||||
"status.collapse": "折叠",
|
||||
"status.filtered": "已过滤",
|
||||
"status.has_audio": "附带音频",
|
||||
"status.has_pictures": "附带图片",
|
||||
"status.has_preview_card": "附带预览卡片",
|
||||
"status.has_video": "附带视频",
|
||||
"status.hide": "隐藏嘟文",
|
||||
"status.in_reply_to": "此嘟文是回复",
|
||||
"status.is_poll": "此嘟文是投票",
|
||||
"status.local_only": "此嘟文仅本站可见",
|
||||
"status.show_filter_reason": "仍然显示",
|
||||
"status.show_less": "部分显示",
|
||||
"status.show_more": "完全显示",
|
||||
"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