Merge branch 'glitch-v4.3.0-rc.1' into develop

This commit is contained in:
Jeremy Kescher 2024-10-03 22:25:40 +02:00
commit 1290a5609d
No known key found for this signature in database
GPG key ID: 80A419A7A613DFA4
639 changed files with 7788 additions and 5536 deletions

View file

@ -69,7 +69,7 @@ services:
hard: -1 hard: -1
libretranslate: libretranslate:
image: libretranslate/libretranslate:v1.6.0 image: libretranslate/libretranslate:v1.6.1
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- lt-data:/home/libretranslate/.local - lt-data:/home/libretranslate/.local

View file

@ -4,6 +4,12 @@ All changes to Catstodon that aren't Mastodon or glitch-soc Mastodon changes wil
All release dates, as well as most other dates, are intended to be read as "within the day, in UTC time." All release dates, as well as most other dates, are intended to be read as "within the day, in UTC time."
## [v4.3.0-rc.1+cat.1.0.0] - UNRELEASED
- Upstream changes
- A lot of emoji reaction patch changes along with upstream changes
- Thanks, Essem!
## [v4.3.0-beta.2+cat.1.0.1] - 2024-09-20 ## [v4.3.0-beta.2+cat.1.0.1] - 2024-09-20
- Fix clicking posts navigating to an invalid ("undefined") page - Fix clicking posts navigating to an invalid ("undefined") page

View file

@ -10,12 +10,13 @@ The following changelog entries focus on changes visible to users, administrator
- **Add confirmation interstitial instead of silently redirecting logged-out visitors to remote resources** (#27792, #28902, and #30651 by @ClearlyClaire and @Gargron)\ - **Add confirmation interstitial instead of silently redirecting logged-out visitors to remote resources** (#27792, #28902, and #30651 by @ClearlyClaire and @Gargron)\
This fixes a longstanding open redirect in Mastodon, at the cost of added friction when local links to remote resources are shared. This fixes a longstanding open redirect in Mastodon, at the cost of added friction when local links to remote resources are shared.
- 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 by @ClearlyClaire) - Change `form-action` Content-Security-Policy directive to be more restrictive (#26897 by @ClearlyClaire)
- Update dependencies - Update dependencies
### Added ### Added
- **Add server-side notification grouping** (#29889, #30576, #30685, #30688, #30707, #30776, #30779, #30781, #30440, #31062, #31098, #31076, #31111, #31123, #31223, #31214, #31224, #31299, #31325, #31347, #31304, #31326, #31384, #31403, #31433, #31509, #31486, #31513, #31592, #31594, #31638, #31746, #31652, #31709, #31725, #31745, #31613, #31657, #31840, #31610 and #31929 by @ClearlyClaire, @Gargron, @mgmn, and @renchap)\ - **Add server-side notification grouping** (#29889, #30576, #30685, #30688, #30707, #30776, #30779, #30781, #30440, #31062, #31098, #31076, #31111, #31123, #31223, #31214, #31224, #31299, #31325, #31347, #31304, #31326, #31384, #31403, #31433, #31509, #31486, #31513, #31592, #31594, #31638, #31746, #31652, #31709, #31725, #31745, #31613, #31657, #31840, #31610, #31929, #32089 and #32085 by @ClearlyClaire, @Gargron, @mgmn, and @renchap)\
Group notifications of the same type for the same target, so that your notifications no longer get cluttered by boost and favorite notifications as soon as a couple of your posts get traction.\ Group notifications of the same type for the same target, so that your notifications no longer get cluttered by boost and favorite notifications as soon as a couple of your posts get traction.\
This is done server-side so that clients can efficiently get relevant groups without having to go through numerous pages of individual notifications.\ This is done server-side so that clients can efficiently get relevant groups without having to go through numerous pages of individual notifications.\
As part of this, the visual design of the entire notifications feature has been revamped.\ As part of this, the visual design of the entire notifications feature has been revamped.\
@ -27,7 +28,7 @@ The following changelog entries focus on changes visible to users, administrator
- `GET /api/v2/notifications/:group_key/accounts`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts - `GET /api/v2/notifications/:group_key/accounts`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts
- `POST /api/v2/notifications/:group_key/dimsiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group - `POST /api/v2/notifications/:group_key/dimsiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group
- `GET /api/v2/notifications/:unread_count`: https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count - `GET /api/v2/notifications/:unread_count`: https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count
- **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, and #31723 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\ - **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, #31723 and #32062 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\
The old “Block notifications from non-followers”, “Block notifications from people you don't follow” and “Block direct messages from people you don't follow” notification settings have been replaced by a new set of settings found directly in the notification column.\ The old “Block notifications from non-followers”, “Block notifications from people you don't follow” and “Block direct messages from people you don't follow” notification settings have been replaced by a new set of settings found directly in the notification column.\
You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\ You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\
Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\ Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\
@ -76,7 +77,11 @@ The following changelog entries focus on changes visible to users, administrator
Clicking the domain of a user in their profile will now open a tooltip with a short explanation about servers and federation. Clicking the domain of a user in their profile will now open a tooltip with a short explanation about servers and federation.
- **Add support for Redis sentinel** (#31694, #31623, #31744, #31767, and #31768 by @ThisIsMissEm and @oneiros)\ - **Add support for Redis sentinel** (#31694, #31623, #31744, #31767, and #31768 by @ThisIsMissEm and @oneiros)\
See https://docs.joinmastodon.org/admin/scaling/#redis-sentinel See https://docs.joinmastodon.org/admin/scaling/#redis-sentinel
- Add ability to reorder uploaded media before posting in web UI (#28456 by @Gargron) - **Add ability to reorder uploaded media before posting in web UI** (#28456 and #32093 by @Gargron)
- Add “A Mastodon update is available.” message on admin dashboard for non-bugfix updates (#32106 by @ClearlyClaire)
- Add ability to view alt text by clicking the ALT badge in web UI (#32058 by @Gargron)
- Add preview of followers removed in domain block modal in web UI (#32032 and #32105 by @ClearlyClaire and @Gargron)
- Add reblogs and favourites counts to statuses in ActivityPub (#32007 by @Gargron)
- Add moderation interface for searching hashtags (#30880 by @ThisIsMissEm) - Add moderation interface for searching hashtags (#30880 by @ThisIsMissEm)
- Add ability for admins to configure instance favicon and logo (#30040, #30208, #30259, #30375, #30734, #31016, and #30205 by @ClearlyClaire, @FawazFarid, @JasonPunyon, @mgmn, and @renchap)\ - Add ability for admins to configure instance favicon and logo (#30040, #30208, #30259, #30375, #30734, #31016, and #30205 by @ClearlyClaire, @FawazFarid, @JasonPunyon, @mgmn, and @renchap)\
This is also exposed through the REST API: https://docs.joinmastodon.org/entities/Instance/#icon This is also exposed through the REST API: https://docs.joinmastodon.org/entities/Instance/#icon
@ -122,14 +127,14 @@ The following changelog entries focus on changes visible to users, administrator
- Add Interlingue and Interlingua to interface languages (#28630 and #30828 by @Dhghomon and @renchap) - Add Interlingue and Interlingua to interface languages (#28630 and #30828 by @Dhghomon and @renchap)
- Add Kashubian, Pennsylvania Dutch, Vai, Jawi Malay, Mohawk and Low German to posting languages (#26024, #26634, #27136, #29098, #27115, and #27434 by @EngineerDali, @HelgeKrueger, and @gunchleoc) - Add Kashubian, Pennsylvania Dutch, Vai, Jawi Malay, Mohawk and Low German to posting languages (#26024, #26634, #27136, #29098, #27115, and #27434 by @EngineerDali, @HelgeKrueger, and @gunchleoc)
- Add option to use native Ruby driver for Redis through `REDIS_DRIVER=ruby` (#30717 by @vmstan) - Add option to use native Ruby driver for Redis through `REDIS_DRIVER=ruby` (#30717 by @vmstan)
- Add support for libvips in addition to ImageMagick (#30090, #30590, #30597, #30632, #30857, #30869, and #30858 by @ClearlyClaire, @Gargron, and @mjankowski)\ - Add support for libvips in addition to ImageMagick (#30090, #30590, #30597, #30632, #30857, #30869, #30858 and #32104 by @ClearlyClaire, @Gargron, and @mjankowski)\
Server admins can now use libvips as a faster and lighter alternative to ImageMagick for processing user-uploaded images.\ Server admins can now use libvips as a faster and lighter alternative to ImageMagick for processing user-uploaded images.\
This requires libvips 8.13 or newer, and needs to be enabled with `MASTODON_USE_LIBVIPS=true`.\ This requires libvips 8.13 or newer, and needs to be enabled with `MASTODON_USE_LIBVIPS=true`.\
This is enabled by default in the official Docker images, and is intended to completely replace ImageMagick in the future. This is enabled by default in the official Docker images, and is intended to completely replace ImageMagick in the future.
- Add validations to `Web::PushSubscription` (#30540 and #30542 by @ThisIsMissEm) - Add validations to `Web::PushSubscription` (#30540 and #30542 by @ThisIsMissEm)
- Add anchors to each authorized application in `/oauth/authorized_applications` (#31677 by @fowl2) - Add anchors to each authorized application in `/oauth/authorized_applications` (#31677 by @fowl2)
- Add active animation to header settings button (#30221, #30307, and #30388 by @daudix) - Add active animation to header settings button (#30221, #30307, and #30388 by @daudix)
- Add OpenTelemetry instrumentation (#30130, #30322, #30353, and #30350 by @julianocosta89, @renchap, and @robbkidd)\ - Add OpenTelemetry instrumentation (#30130, #30322, #30353, #30350 and #31998 by @julianocosta89, @renchap, @robbkidd and @timetinytim)\
See https://docs.joinmastodon.org/admin/config/#otel for documentation See https://docs.joinmastodon.org/admin/config/#otel for documentation
- Add API to get multiple accounts and statuses (#27871 and #30465 by @ClearlyClaire)\ - Add API to get multiple accounts and statuses (#27871 and #30465 by @ClearlyClaire)\
This adds `GET /api/v1/accounts` and `GET /api/v1/statuses` to the REST API, see https://docs.joinmastodon.org/methods/accounts/#index and https://docs.joinmastodon.org/methods/statuses/#index This adds `GET /api/v1/accounts` and `GET /api/v1/statuses` to the REST API, see https://docs.joinmastodon.org/methods/accounts/#index and https://docs.joinmastodon.org/methods/statuses/#index
@ -138,7 +143,6 @@ The following changelog entries focus on changes visible to users, administrator
- Add RFC8414 OAuth 2.0 server metadata (#29191 by @ThisIsMissEm) - Add RFC8414 OAuth 2.0 server metadata (#29191 by @ThisIsMissEm)
- Add loading indicator and empty result message to advanced interface search (#30085 by @ClearlyClaire) - Add loading indicator and empty result message to advanced interface search (#30085 by @ClearlyClaire)
- Add `profile` OAuth 2.0 scope, allowing more limited access to user data (#29087 and #30357 by @ThisIsMissEm) - Add `profile` OAuth 2.0 scope, allowing more limited access to user data (#29087 and #30357 by @ThisIsMissEm)
- Add global Regexp timeout (#31928 by @ClearlyClaire)
- Add the role ID to the badge component (#29707 by @renchap) - Add the role ID to the badge component (#29707 by @renchap)
- Add diagnostic message for failure during CLI search deploy (#29462 by @mjankowski) - Add diagnostic message for failure during CLI search deploy (#29462 by @mjankowski)
- Add pagination `Link` headers on API accounts/statuses when pinned true (#29442 by @mjankowski) - Add pagination `Link` headers on API accounts/statuses when pinned true (#29442 by @mjankowski)
@ -167,15 +171,15 @@ The following changelog entries focus on changes visible to users, administrator
- **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, and #31525 by @ClearlyClaire, @Gargron, @arbolitoloco1, @mjankowski, @nclm, @renchap, @ronilaukkarinen, and @zunda)\
This changes all the interface icons from FontAwesome to Material Symbols for a more modern look, consistent with the official Mastodon Android app.\ This changes all the interface icons from FontAwesome to Material Symbols for a more modern look, consistent with the official Mastodon Android app.\
In addition, better care is given to pixel alignment, and icon variants are used to better highlight active/inactive state. In addition, better care is given to pixel alignment, and icon variants are used to better highlight active/inactive state.
- **Change design of compose form in web UI** (#28119, #29059, #29248, #29372, #29384, #29417, #29456, #29406, #29651, #29659, and #31889 by @ClearlyClaire, @Gargron, @eai04191, @hinaloe, and @ronilaukkarinen)\ - **Change design of compose form in web UI** (#28119, #29059, #29248, #29372, #29384, #29417, #29456, #29406, #29651, #29659, #31889 and #32033 by @ClearlyClaire, @Gargron, @eai04191, @hinaloe, and @ronilaukkarinen)\
The compose form has been completely redesigned for a more modern and consistent look, as well as spelling out the chosen privacy setting and language name at all times.\ The compose form has been completely redesigned for a more modern and consistent look, as well as spelling out the chosen privacy setting and language name at all times.\
As part of this, the “Unlisted” privacy setting has been renamed to “Quiet public”. As part of this, the “Unlisted” privacy setting has been renamed to “Quiet public”.
- **Change design of modals in the web UI** (#29576, #29614, #29640, #29644, #30131, #30884, #31399, #31555, #31752, #31801, #31883, #31844, #31864, and #31943 by @ClearlyClaire, @Gargron, @tribela and @vmstan)\ - **Change design of modals in the web UI** (#29576, #29614, #29640, #29644, #30131, #30884, #31399, #31555, #31752, #31801, #31883, #31844, #31864, and #31943 by @ClearlyClaire, @Gargron, @tribela and @vmstan)\
The mute, block, and domain block confirmation modals have been completely redesigned to be clearer and include more detailed information on the action to be performed.\ The mute, block, and domain block confirmation modals have been completely redesigned to be clearer and include more detailed information on the action to be performed.\
They also have a more modern and consistent design, along with other confirmation modals in the application. They also have a more modern and consistent design, along with other confirmation modals in the application.
- **Change colors throughout the web UI** (#29522, #29584, #29653, #29779, #29803, #29809, #29808, #29828, #31034, #31168, #31266, #31348, #31349, #31361, and #31510 by @ClearlyClaire, @Gargron, @renchap, and @vmstan) - **Change colors throughout the web UI** (#29522, #29584, #29653, #29779, #29803, #29809, #29808, #29828, #31034, #31168, #31266, #31348, #31349, #31361, #31510 and #32128 by @ClearlyClaire, @Gargron, @mjankowski, @renchap, and @vmstan)
- **Change onboarding prompt to follow suggestions carousel in web UI** (#28878, #29272, and #31912 by @Gargron) - **Change onboarding prompt to follow suggestions carousel in web UI** (#28878, #29272, and #31912 by @Gargron)
- **Change email templates** (#28416, #28755, #28814, #29064, #28883, #29470, #29607, #29761, #29760, and #29879 by @ClearlyClaire, @Gargron, @hteumeuleu, and @mjankowski)\ - **Change email templates** (#28416, #28755, #28814, #29064, #28883, #29470, #29607, #29761, #29760, #29879, #32073 and #32132 by @c960657, @ClearlyClaire, @Gargron, @hteumeuleu, and @mjankowski)\
All emails to end-users have been completely redesigned with a fresh new look, providing more information while making them easier to read and keeping maximum compatibility across mail clients. All emails to end-users have been completely redesigned with a fresh new look, providing more information while making them easier to read and keeping maximum compatibility across mail clients.
- **Change follow recommendations algorithm** (#28314, #28433, #29017, #29108, #29306, #29550, #29619, and #31474 by @ClearlyClaire, @Gargron, @kernal053, @mjankowski, and @wheatear-dev)\ - **Change follow recommendations algorithm** (#28314, #28433, #29017, #29108, #29306, #29550, #29619, and #31474 by @ClearlyClaire, @Gargron, @kernal053, @mjankowski, and @wheatear-dev)\
This replaces the “past interactions” recommendation algorithm with a “friends of friends” algorithm that suggests accounts followed by people you follow, and a “similar profiles” algorithm that suggests accounts with a profile similar to your most recent follows.\ This replaces the “past interactions” recommendation algorithm with a “friends of friends” algorithm that suggests accounts followed by people you follow, and a “similar profiles” algorithm that suggests accounts with a profile similar to your most recent follows.\
@ -188,10 +192,17 @@ The following changelog entries focus on changes visible to users, administrator
Administrators may need to update their setup accordingly. Administrators may need to update their setup accordingly.
- Change how content warnings and filters are displayed in web UI (#31365, and #31761 by @Gargron) - Change how content warnings and filters are displayed in web UI (#31365, and #31761 by @Gargron)
- Change preview card processing to ignore `undefined` as canonical url (#31882 by @oneiros) - Change preview card processing to ignore `undefined` as canonical url (#31882 by @oneiros)
- Change embedded posts to use web UI (#31766 by @Gargron) - Change embedded posts to use web UI (#31766 and #32135 by @Gargron)
- Change inner borders in media galleries in web UI (#31852 by @Gargron) - Change inner borders in media galleries in web UI (#31852 by @Gargron)
- Change design of hide media button in web UI (#31807 by @Gargron) - Change design of media attachments and profile media tab in web UI (#31807, #32048, and #31967 by @Gargron)
- Change labels on thread indicators in web UI (#31806 by @Gargron) - Change labels on thread indicators in web UI (#31806 by @Gargron)
- Change label of "Data export" menu item in settings interface (#32099 by @c960657)
- Change responsive break points on navigation panel in web UI (#32034 by @Gargron)
- Change cursor to `not-allowed` on disabled buttons (#32076 by @mjankowski)
- Change OAuth authorization prompt to not refer to apps as “third-party” (#32005 by @Gargron)
- Change Mastodon to issue correct HTTP signatures by default (#31994 by @ClearlyClaire)
- Change zoom icon in web UI (#29683 by @Gargron)
- Change directory page to use URL query strings for options (#31980, #31977 and #31984 by @ClearlyClaire and @renchap)
- Change report action buttons to be disabled when action has already been taken (#31773, #31822, and #31899 by @ClearlyClaire and @ThisIsMissEm) - Change report action buttons to be disabled when action has already been taken (#31773, #31822, and #31899 by @ClearlyClaire and @ThisIsMissEm)
- Change width of columns in advanced web UI (#31762 by @Gargron) - Change width of columns in advanced web UI (#31762 by @Gargron)
- Change design of unread conversations in web UI (#31763 by @Gargron) - Change design of unread conversations in web UI (#31763 by @Gargron)
@ -254,6 +265,7 @@ The following changelog entries focus on changes visible to users, administrator
### Removed ### Removed
- Remove unused E2EE messaging code and related `crypto` OAuth scope (#31193, #31945, #31963, and #31964 by @ClearlyClaire and @mjankowski)
- Remove StatsD integration (replaced by OpenTelemetry) (#30240 by @mjankowski) - Remove StatsD integration (replaced by OpenTelemetry) (#30240 by @mjankowski)
- Remove `CacheBuster` default options (#30718 by @mjankowski) - Remove `CacheBuster` default options (#30718 by @mjankowski)
- Remove home marker updates from the Web UI (#22721 by @davbeck)\ - Remove home marker updates from the Web UI (#22721 by @davbeck)\
@ -269,9 +281,21 @@ The following changelog entries focus on changes visible to users, administrator
- Fix log out from user menu not working on Safari (#31402 by @renchap) - Fix log out from user menu not working on Safari (#31402 by @renchap)
- Fix various issues when in link preview card generation (#28748, #30017, #30362, #30173, #30853, #30929, #30933, #30957, #30987, and #31144 by @adamniedzielski, @oneiros, @phocks, @timothyjrogers, and @tribela) - Fix various issues when in link preview card generation (#28748, #30017, #30362, #30173, #30853, #30929, #30933, #30957, #30987, and #31144 by @adamniedzielski, @oneiros, @phocks, @timothyjrogers, and @tribela)
- Fix handling of missing links in Webfinger responses (#31030 by @adamniedzielski) - Fix handling of missing links in Webfinger responses (#31030 by @adamniedzielski)
- Fix error when accepting an appeal for sensitive posts deleted in the meantime (#32037 by @ClearlyClaire)
- Fix error when encountering reblog of deleted post in feed rebuild (#32001 by @ClearlyClaire)
- Fix Safari browser glitch related to horizontal scrolling (#31960 by @Gargron)
- Fix 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 by @renchap)
- Fix spurious error notifications for aborted requests in web UI (#31952 by @c960657)
- Fix HTTP 500 error in `/api/v1/polls/:id/votes` when required `choices` parameter is missing (#25598 by @danielmbrasil) - Fix HTTP 500 error in `/api/v1/polls/:id/votes` when required `choices` parameter is missing (#25598 by @danielmbrasil)
- Fix security context sometimes not being added in LD-Signed activities (#31871 by @ClearlyClaire) - Fix security context sometimes not being added in LD-Signed activities (#31871 by @ClearlyClaire)
- Fix cross-origin loading of `inert.css` polyfill (#30687 by @louis77) - Fix cross-origin loading of `inert.css` polyfill (#30687 by @louis77)
- Fix wrapping in dashboard quick access buttons (#32043 by @renchap)
- Fix recently used tags hint being displayed in profile edition page when there is none (#32120 by @mjankowski)
- Fix checkbox lists on narrow screens in the settings interface (#32112 by @mjankowski)
- Fix the position of status action buttons being affected by interaction counters (#32084 by @renchap)
- Fix the summary of converted ActivityPub object types to be treated as HTML (#28629 by @Menrath)
- Fix cutoff of instance name in sign-up form (#30598 by @oneiros) - Fix cutoff of instance name in sign-up form (#30598 by @oneiros)
- Fix invalid date searches returning 503 errors (#31526 by @notchairmk) - Fix invalid date searches returning 503 errors (#31526 by @notchairmk)
- Fix invalid `visibility` values in `POST /api/v1/statuses` returning 500 errors (#31571 by @c960657) - Fix invalid `visibility` values in `POST /api/v1/statuses` returning 500 errors (#31571 by @c960657)
@ -285,7 +309,7 @@ The following changelog entries focus on changes visible to users, administrator
- Fix “Redirect URI” field not being marked as required in “New application” form (#30311 by @ThisIsMissEm) - Fix “Redirect URI” field not being marked as required in “New application” form (#30311 by @ThisIsMissEm)
- Fix right-to-left text in preview cards (#30930 by @ClearlyClaire) - Fix right-to-left text in preview cards (#30930 by @ClearlyClaire)
- Fix rack attack `match_type` value typo in logging config (#30514 by @mjankowski) - Fix rack attack `match_type` value typo in logging config (#30514 by @mjankowski)
- Fix various cases of duplicate, missing, or inconsistent borders or scrollbar styles (#31068, #31286, #31268, #31275, #31284, #31305, #31346, #31372, #31373, #31389, #31432, #31391, and #31445 by @valtlai and @vmstan) - Fix various cases of duplicate, missing, or inconsistent borders or scrollbar styles (#31068, #31286, #31268, #31275, #31284, #31305, #31346, #31372, #31373, #31389, #31432, #31391, #31445 and #32091 by @ClearlyClaire, @valtlai and @vmstan)
- Fix race condition in `POST /api/v1/push/subscription` (#30166 by @ClearlyClaire) - Fix race condition in `POST /api/v1/push/subscription` (#30166 by @ClearlyClaire)
- Fix post deletion not being delayed when those are part of an account warning (#30163 by @ClearlyClaire) - Fix post deletion not being delayed when those are part of an account warning (#30163 by @ClearlyClaire)
- Fix rendering error on `/start` when not logged in (#30023 by @timothyjrogers) - Fix rendering error on `/start` when not logged in (#30023 by @timothyjrogers)

View file

@ -47,7 +47,6 @@ gem 'color_diff', '~> 0.1'
gem 'csv', '~> 3.2' gem 'csv', '~> 3.2'
gem 'discard', '~> 1.2' gem 'discard', '~> 1.2'
gem 'doorkeeper', '~> 5.6' gem 'doorkeeper', '~> 5.6'
gem 'ed25519', '~> 1.3'
gem 'fast_blank', '~> 1.0' gem 'fast_blank', '~> 1.0'
gem 'fastimage' gem 'fastimage'
gem 'hiredis', '~> 0.6' gem 'hiredis', '~> 0.6'

View file

@ -100,20 +100,20 @@ GEM
attr_required (1.0.2) attr_required (1.0.2)
awrence (1.2.1) awrence (1.2.1)
aws-eventstream (1.3.0) aws-eventstream (1.3.0)
aws-partitions (1.974.0) aws-partitions (1.978.0)
aws-sdk-core (3.205.0) aws-sdk-core (3.209.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.91.0) aws-sdk-kms (1.94.0)
aws-sdk-core (~> 3, >= 3.205.0) aws-sdk-core (~> 3, >= 3.207.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.162.0) aws-sdk-s3 (1.166.0)
aws-sdk-core (~> 3, >= 3.205.0) aws-sdk-core (~> 3, >= 3.207.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.9.1) aws-sigv4 (1.10.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3) azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0) azure-storage-common (~> 2.0)
@ -134,7 +134,7 @@ GEM
bindata (2.5.0) bindata (2.5.0)
binding_of_caller (1.0.1) binding_of_caller (1.0.1)
debug_inspector (>= 1.2.0) debug_inspector (>= 1.2.0)
blurhash (0.1.7) blurhash (0.1.8)
bootsnap (1.18.4) bootsnap (1.18.4)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (6.2.1) brakeman (6.2.1)
@ -197,7 +197,7 @@ GEM
railties (>= 4.1.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise-two-factor (5.1.0) devise-two-factor (6.0.0)
activesupport (~> 7.0) activesupport (~> 7.0)
devise (~> 4.0) devise (~> 4.0)
railties (~> 7.0) railties (~> 7.0)
@ -212,9 +212,8 @@ GEM
domain_name (0.6.20240107) domain_name (0.6.20240107)
doorkeeper (5.7.1) doorkeeper (5.7.1)
railties (>= 5) railties (>= 5)
dotenv (3.1.2) dotenv (3.1.4)
drb (2.2.1) drb (2.2.1)
ed25519 (1.3.0)
elasticsearch (7.17.11) elasticsearch (7.17.11)
elasticsearch-api (= 7.17.11) elasticsearch-api (= 7.17.11)
elasticsearch-transport (= 7.17.11) elasticsearch-transport (= 7.17.11)
@ -290,7 +289,7 @@ GEM
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
google-protobuf (3.25.4) google-protobuf (3.25.5)
googleapis-common-protos-types (1.15.0) googleapis-common-protos-types (1.15.0)
google-protobuf (>= 3.18, < 5.a) google-protobuf (>= 3.18, < 5.a)
haml (6.3.0) haml (6.3.0)
@ -348,7 +347,7 @@ GEM
activesupport (>= 3.0) activesupport (>= 3.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
io-console (0.7.2) io-console (0.7.2)
irb (1.14.0) irb (1.14.1)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2) jmespath (1.6.2)
@ -407,7 +406,7 @@ GEM
llhttp-ffi (0.5.0) llhttp-ffi (0.5.0)
ffi-compiler (~> 1.0) ffi-compiler (~> 1.0)
rake (~> 13.0) rake (~> 13.0)
logger (1.6.0) logger (1.6.1)
lograge (0.14.0) lograge (0.14.0)
actionpack (>= 4) actionpack (>= 4)
activesupport (>= 4) activesupport (>= 4)
@ -429,7 +428,7 @@ GEM
addressable (~> 2.5) addressable (~> 2.5)
azure-storage-blob (~> 2.0.1) azure-storage-blob (~> 2.0.1)
hashie (~> 5.0) hashie (~> 5.0)
memory_profiler (1.0.2) memory_profiler (1.1.0)
mime-types (3.5.2) mime-types (3.5.2)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2024.0820) mime-types-data (3.2024.0820)
@ -602,7 +601,7 @@ GEM
actionmailer (>= 3) actionmailer (>= 3)
net-smtp net-smtp
premailer (~> 1.7, >= 1.7.9) premailer (~> 1.7, >= 1.7.9)
propshaft (1.0.0) propshaft (1.0.1)
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
@ -610,7 +609,7 @@ GEM
psych (5.1.2) psych (5.1.2)
stringio stringio
public_suffix (6.0.1) public_suffix (6.0.1)
puma (6.4.2) puma (6.4.3)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.4.0) pundit (2.4.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
@ -782,7 +781,7 @@ GEM
scenic (1.8.0) scenic (1.8.0)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
selenium-webdriver (4.24.0) selenium-webdriver (4.25.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
@ -894,7 +893,7 @@ GEM
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
semantic_range (>= 2.3.0) semantic_range (>= 2.3.0)
webrick (1.8.1) webrick (1.8.2)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.7.6) websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
@ -937,7 +936,6 @@ DEPENDENCIES
discard (~> 1.2) discard (~> 1.2)
doorkeeper (~> 5.6) doorkeeper (~> 5.6)
dotenv dotenv
ed25519 (~> 1.3)
email_spec email_spec
fabrication (~> 2.30) fabrication (~> 2.30)
faker (~> 3.2) faker (~> 3.2)

View file

@ -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

View file

@ -22,8 +22,6 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
@items = @items.map { |item| item.distributable? ? item : ActivityPub::TagManager.instance.uri_for(item) } @items = @items.map { |item| item.distributable? ? item : ActivityPub::TagManager.instance.uri_for(item) }
when 'tags' when 'tags'
@items = for_signed_account { @account.featured_tags } @items = for_signed_account { @account.featured_tags }
when 'devices'
@items = @account.devices
else else
not_found not_found
end end
@ -31,7 +29,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
def set_size def set_size
case params[:id] case params[:id]
when 'featured', 'devices', 'tags' when 'featured', 'tags'
@size = @items.size @size = @items.size
else else
not_found not_found
@ -42,7 +40,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
case params[:id] case params[:id]
when 'featured' when 'featured'
@type = :ordered @type = :ordered
when 'devices', 'tags' when 'tags'
@type = :unordered @type = :unordered
else else
not_found not_found

View 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

View file

@ -12,7 +12,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
before_action :set_replies before_action :set_replies
def index def index
expires_in 0, public: public_fetch_mode? expires_in 0, public: @status.distributable? && public_fetch_mode?
render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true
end end

View 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

View file

@ -7,7 +7,7 @@ class Api::OEmbedController < Api::BaseController
before_action :require_public_status! before_action :require_public_status!
def show def show
render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default render json: @status, serializer: OEmbedSerializer, width: params[:maxwidth], height: params[:maxheight]
end end
private private
@ -23,12 +23,4 @@ class Api::OEmbedController < Api::BaseController
def status_finder def status_finder
StatusFinder.new(params[:url]) StatusFinder.new(params[:url])
end end
def maxwidth_or_default
(params[:maxwidth].presence || 400).to_i
end
def maxheight_or_default
params[:maxheight].present? ? params[:maxheight].to_i : nil
end
end end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -7,6 +7,8 @@ class Api::V1::Peers::SearchController < Api::BaseController
skip_before_action :require_authenticated_user!, unless: :limited_federation_mode? skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
skip_around_action :set_locale skip_around_action :set_locale
LIMIT = 10
vary_by '' vary_by ''
def index def index
@ -35,10 +37,10 @@ class Api::V1::Peers::SearchController < Api::BaseController
field: 'accounts_count', field: 'accounts_count',
modifier: 'log2p', modifier: 'log2p',
}, },
}).limit(10).pluck(:domain) }).limit(LIMIT).pluck(:domain)
else else
domain = normalized_domain domain = normalized_domain
@domains = Instance.searchable.domain_starts_with(domain).limit(10).pluck(:domain) @domains = Instance.searchable.domain_starts_with(domain).limit(LIMIT).pluck(:domain)
end end
rescue Addressable::URI::InvalidURIError rescue Addressable::URI::InvalidURIError
@domains = [] @domains = []

View file

@ -9,7 +9,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController
return not_found if @status.hidden? return not_found if @status.hidden?
if @status.local? if @status.local?
render json: @status, serializer: OEmbedSerializer, width: 400 render json: @status, serializer: OEmbedSerializer
else else
return not_found unless user_signed_in? return not_found unless user_signed_in?

View file

@ -20,11 +20,6 @@ class Auth::SessionsController < Devise::SessionsController
p.form_action(false) p.form_action(false)
end end
def check_suspicious!
user = find_user
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
end
def create def create
super do |resource| super do |resource|
# We only need to call this if this hasn't already been # We only need to call this if this hasn't already been
@ -101,6 +96,11 @@ class Auth::SessionsController < Devise::SessionsController
private private
def check_suspicious!
user = find_user
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
end
def home_paths(resource) def home_paths(resource)
paths = [about_path, '/explore'] paths = [about_path, '/explore']

View file

@ -31,7 +31,7 @@ module WebAppControllerConcern
def redirect_unauthenticated_to_permalinks! def redirect_unauthenticated_to_permalinks!
return if user_signed_in? # NOTE: Different from upstream because we allow moved users to log in return if user_signed_in? # NOTE: Different from upstream because we allow moved users to log in
permalink_redirector = PermalinkRedirector.new(request.path) permalink_redirector = PermalinkRedirector.new(request.original_fullpath)
return if permalink_redirector.redirect_path.blank? return if permalink_redirector.redirect_path.blank?
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?

View file

@ -24,23 +24,6 @@ module ContextHelper
indexable: { 'toot' => 'http://joinmastodon.org/ns#', 'indexable' => 'toot:indexable' }, indexable: { 'toot' => 'http://joinmastodon.org/ns#', 'indexable' => 'toot:indexable' },
memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' }, memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' },
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' }, voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
olm: {
'toot' => 'http://joinmastodon.org/ns#',
'Device' => 'toot:Device',
'Ed25519Signature' => 'toot:Ed25519Signature',
'Ed25519Key' => 'toot:Ed25519Key',
'Curve25519Key' => 'toot:Curve25519Key',
'EncryptedMessage' => 'toot:EncryptedMessage',
'publicKeyBase64' => 'toot:publicKeyBase64',
'deviceId' => 'toot:deviceId',
'claim' => { '@type' => '@id', '@id' => 'toot:claim' },
'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' },
'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' },
'devices' => { '@type' => '@id', '@id' => 'toot:devices' },
'messageFranking' => 'toot:messageFranking',
'messageType' => 'toot:messageType',
'cipherText' => 'toot:cipherText',
},
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } }, attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
}.freeze }.freeze

View file

@ -10,6 +10,7 @@ module SettingsHelper
end end
def featured_tags_hint(recently_used_tags) def featured_tags_hint(recently_used_tags)
recently_used_tags.present? &&
safe_join( safe_join(
[ [
t('simple_form.hints.featured_tag.name'), t('simple_form.hints.featured_tag.name'),

View file

@ -1,4 +1,5 @@
import { browserHistory } from 'flavours/glitch/components/router'; import { browserHistory } from 'flavours/glitch/components/router';
import { debounceWithDispatchAndArguments } from 'flavours/glitch/utils/debounce';
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
@ -462,6 +463,20 @@ export function expandFollowingFail(id, error) {
}; };
} }
const debouncedFetchRelationships = debounceWithDispatchAndArguments((dispatch, ...newAccountIds) => {
if (newAccountIds.length === 0) {
return;
}
dispatch(fetchRelationshipsRequest(newAccountIds));
api().get(`/api/v1/accounts/relationships?with_suspended=true&${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
dispatch(fetchRelationshipsSuccess({ relationships: response.data }));
}).catch(error => {
dispatch(fetchRelationshipsFail(error));
});
}, { delay: 500 });
export function fetchRelationships(accountIds) { export function fetchRelationships(accountIds) {
return (dispatch, getState) => { return (dispatch, getState) => {
const state = getState(); const state = getState();
@ -473,13 +488,7 @@ export function fetchRelationships(accountIds) {
return; return;
} }
dispatch(fetchRelationshipsRequest(newAccountIds)); debouncedFetchRelationships(dispatch, ...newAccountIds);
api().get(`/api/v1/accounts/relationships?with_suspended=true&${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
dispatch(fetchRelationshipsSuccess({ relationships: response.data }));
}).catch(error => {
dispatch(fetchRelationshipsFail(error));
});
}; };
} }

View file

@ -1,5 +1,7 @@
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import { AxiosError } from 'axios';
const messages = defineMessages({ const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
@ -50,6 +52,11 @@ export const showAlertForError = (error, skipNotFound = false) => {
}); });
} }
// An aborted request, e.g. due to reloading the browser window, it not really error
if (error.code === AxiosError.ECONNABORTED) {
return { type: ALERT_NOOP };
}
console.error(error); console.error(error);
return showAlert({ return showAlert({

View file

@ -68,10 +68,15 @@ function dispatchAssociatedRecords(
dispatch(importFetchedStatuses(fetchedStatuses)); dispatch(importFetchedStatuses(fetchedStatuses));
} }
const supportedGroupedNotificationTypes = ['favourite', 'reblog'];
export const fetchNotifications = createDataLoadingThunk( export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch', 'notificationGroups/fetch',
async (_params, { getState }) => async (_params, { getState }) =>
apiFetchNotificationGroups({ exclude_types: getExcludedTypes(getState()) }), apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
exclude_types: getExcludedTypes(getState()),
}),
({ notifications, accounts, statuses }, { dispatch }) => { ({ notifications, accounts, statuses }, { dispatch }) => {
dispatch(importFetchedAccounts(accounts)); dispatch(importFetchedAccounts(accounts));
dispatch(importFetchedStatuses(statuses)); dispatch(importFetchedStatuses(statuses));
@ -93,6 +98,7 @@ export const fetchNotificationsGap = createDataLoadingThunk(
'notificationGroups/fetchGap', 'notificationGroups/fetchGap',
async (params: { gap: NotificationGap }, { getState }) => async (params: { gap: NotificationGap }, { getState }) =>
apiFetchNotificationGroups({ apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
max_id: params.gap.maxId, max_id: params.gap.maxId,
exclude_types: getExcludedTypes(getState()), exclude_types: getExcludedTypes(getState()),
}), }),
@ -109,6 +115,7 @@ export const pollRecentNotifications = createDataLoadingThunk(
'notificationGroups/pollRecentNotifications', 'notificationGroups/pollRecentNotifications',
async (_params, { getState }) => { async (_params, { getState }) => {
return apiFetchNotificationGroups({ return apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
max_id: undefined, max_id: undefined,
exclude_types: getExcludedTypes(getState()), exclude_types: getExcludedTypes(getState()),
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones // In slow mode, we don't want to include notifications that duplicate the already-displayed ones

View file

@ -17,6 +17,6 @@ export const updateNotificationsPolicy = createDataLoadingThunk(
(policy: Partial<NotificationPolicy>) => apiUpdateNotificationsPolicy(policy), (policy: Partial<NotificationPolicy>) => apiUpdateNotificationsPolicy(policy),
); );
export const decreasePendingNotificationsCount = createAction<number>( export const decreasePendingRequestsCount = createAction<number>(
'notificationPolicy/decreasePendingNotificationCount', 'notificationPolicy/decreasePendingRequestsCount',
); );

View file

@ -13,11 +13,11 @@ import type {
ApiNotificationJSON, ApiNotificationJSON,
} from 'flavours/glitch/api_types/notifications'; } from 'flavours/glitch/api_types/notifications';
import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses'; import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses';
import type { AppDispatch, RootState } from 'flavours/glitch/store'; import type { AppDispatch } from 'flavours/glitch/store';
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions'; import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
import { importFetchedAccounts, importFetchedStatuses } from './importer'; import { importFetchedAccounts, importFetchedStatuses } from './importer';
import { decreasePendingNotificationsCount } from './notification_policies'; import { decreasePendingRequestsCount } from './notification_policies';
// TODO: refactor with notification_groups // TODO: refactor with notification_groups
function dispatchAssociatedRecords( function dispatchAssociatedRecords(
@ -169,19 +169,11 @@ export const expandNotificationsForRequest = createDataLoadingThunk(
}, },
); );
const selectNotificationCountForRequest = (state: RootState, id: string) => {
const requests = state.notificationRequests.items;
const thisRequest = requests.find((request) => request.id === id);
return thisRequest ? thisRequest.notifications_count : 0;
};
export const acceptNotificationRequest = createDataLoadingThunk( export const acceptNotificationRequest = createDataLoadingThunk(
'notificationRequest/accept', 'notificationRequest/accept',
({ id }: { id: string }) => apiAcceptNotificationRequest(id), ({ id }: { id: string }) => apiAcceptNotificationRequest(id),
(_data, { dispatch, getState, discardLoadData, actionArg: { id } }) => { (_data, { dispatch, discardLoadData }) => {
const count = selectNotificationCountForRequest(getState(), id); dispatch(decreasePendingRequestsCount(1));
dispatch(decreasePendingNotificationsCount(count));
// The payload is not used in any functions // The payload is not used in any functions
return discardLoadData; return discardLoadData;
@ -191,10 +183,8 @@ export const acceptNotificationRequest = createDataLoadingThunk(
export const dismissNotificationRequest = createDataLoadingThunk( export const dismissNotificationRequest = createDataLoadingThunk(
'notificationRequest/dismiss', 'notificationRequest/dismiss',
({ id }: { id: string }) => apiDismissNotificationRequest(id), ({ id }: { id: string }) => apiDismissNotificationRequest(id),
(_data, { dispatch, getState, discardLoadData, actionArg: { id } }) => { (_data, { dispatch, discardLoadData }) => {
const count = selectNotificationCountForRequest(getState(), id); dispatch(decreasePendingRequestsCount(1));
dispatch(decreasePendingNotificationsCount(count));
// The payload is not used in any functions // The payload is not used in any functions
return discardLoadData; return discardLoadData;
@ -204,13 +194,8 @@ export const dismissNotificationRequest = createDataLoadingThunk(
export const acceptNotificationRequests = createDataLoadingThunk( export const acceptNotificationRequests = createDataLoadingThunk(
'notificationRequests/acceptBulk', 'notificationRequests/acceptBulk',
({ ids }: { ids: string[] }) => apiAcceptNotificationRequests(ids), ({ ids }: { ids: string[] }) => apiAcceptNotificationRequests(ids),
(_data, { dispatch, getState, discardLoadData, actionArg: { ids } }) => { (_data, { dispatch, discardLoadData, actionArg: { ids } }) => {
const count = ids.reduce( dispatch(decreasePendingRequestsCount(ids.length));
(count, id) => count + selectNotificationCountForRequest(getState(), id),
0,
);
dispatch(decreasePendingNotificationsCount(count));
// The payload is not used in any functions // The payload is not used in any functions
return discardLoadData; return discardLoadData;
@ -220,13 +205,8 @@ export const acceptNotificationRequests = createDataLoadingThunk(
export const dismissNotificationRequests = createDataLoadingThunk( export const dismissNotificationRequests = createDataLoadingThunk(
'notificationRequests/dismissBulk', 'notificationRequests/dismissBulk',
({ ids }: { ids: string[] }) => apiDismissNotificationRequests(ids), ({ ids }: { ids: string[] }) => apiDismissNotificationRequests(ids),
(_data, { dispatch, getState, discardLoadData, actionArg: { ids } }) => { (_data, { dispatch, discardLoadData, actionArg: { ids } }) => {
const count = ids.reduce( dispatch(decreasePendingRequestsCount(ids.length));
(count, id) => count + selectNotificationCountForRequest(getState(), id),
0,
);
dispatch(decreasePendingNotificationsCount(count));
// The payload is not used in any functions // The payload is not used in any functions
return discardLoadData; return discardLoadData;

View file

@ -10,7 +10,7 @@ import api, { getLinks } from '../api';
import { unescapeHTML } from '../utils/html'; import { unescapeHTML } from '../utils/html';
import { requestNotificationPermission } from '../utils/notifications'; import { requestNotificationPermission } from '../utils/notifications';
import { fetchFollowRequests, fetchRelationships } from './accounts'; import { fetchFollowRequests } from './accounts';
import { import {
importFetchedAccount, importFetchedAccount,
importFetchedAccounts, importFetchedAccounts,
@ -68,14 +68,6 @@ defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
}); });
const fetchRelatedRelationships = (dispatch, notifications) => {
const accountIds = notifications.filter(item => ['follow', 'follow_request', 'admin.sign_up'].indexOf(item.type) !== -1).map(item => item.account.id);
if (accountIds.length > 0) {
dispatch(fetchRelationships(accountIds));
}
};
export const loadPending = () => ({ export const loadPending = () => ({
type: NOTIFICATIONS_LOAD_PENDING, type: NOTIFICATIONS_LOAD_PENDING,
}); });
@ -118,8 +110,6 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered})); dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered}));
fetchRelatedRelationships(dispatch, [notification]);
} else if (playSound && !filtered) { } else if (playSound && !filtered) {
dispatch({ dispatch({
type: NOTIFICATIONS_UPDATE_NOOP, type: NOTIFICATIONS_UPDATE_NOOP,
@ -212,7 +202,6 @@ export function expandNotifications({ maxId = undefined, forceLoad = false }) {
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account))); dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems)); dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
fetchRelatedRelationships(dispatch, response.data);
dispatch(submitMarkers()); dispatch(submitMarkers());
} catch(error) { } catch(error) {
dispatch(expandNotificationsFail(error, isLoadingMore)); dispatch(expandNotificationsFail(error, isLoadingMore));

View file

@ -42,6 +42,9 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => {
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default function api(withAuthorization = true) { export default function api(withAuthorization = true) {
return axios.create({ return axios.create({
transitional: {
clarifyTimeoutError: true,
},
headers: { headers: {
...csrfHeader, ...csrfHeader,
...(withAuthorization ? authorizationTokenFromInitialState() : {}), ...(withAuthorization ? authorizationTokenFromInitialState() : {}),
@ -67,6 +70,7 @@ export async function apiRequest<ApiResponse = unknown>(
args: { args: {
params?: RequestParamsOrData; params?: RequestParamsOrData;
data?: RequestParamsOrData; data?: RequestParamsOrData;
timeout?: number;
} = {}, } = {},
) { ) {
const { data } = await api().request<ApiResponse>({ const { data } = await api().request<ApiResponse>({

View file

@ -31,6 +31,7 @@ export const apiFetchNotifications = async (
export const apiFetchNotificationGroups = async (params?: { export const apiFetchNotificationGroups = async (params?: {
url?: string; url?: string;
grouped_types?: string[];
exclude_types?: string[]; exclude_types?: string[];
max_id?: string; max_id?: string;
since_id?: string; since_id?: string;
@ -91,5 +92,5 @@ export const apiAcceptNotificationRequests = async (id: string[]) => {
}; };
export const apiDismissNotificationRequests = async (id: string[]) => { export const apiDismissNotificationRequests = async (id: string[]) => {
return apiRequestPost('v1/notifications/dismiss/dismiss', { id }); return apiRequestPost('v1/notifications/requests/dismiss', { id });
}; };

View 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>
</>
);
};

View file

@ -7,6 +7,7 @@ interface BaseProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> { extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
block?: boolean; block?: boolean;
secondary?: boolean; secondary?: boolean;
dangerous?: boolean;
} }
interface PropsChildren extends PropsWithChildren<BaseProps> { interface PropsChildren extends PropsWithChildren<BaseProps> {
@ -26,6 +27,7 @@ export const Button: React.FC<Props> = ({
disabled, disabled,
block, block,
secondary, secondary,
dangerous,
className, className,
title, title,
text, text,
@ -46,6 +48,7 @@ export const Button: React.FC<Props> = ({
className={classNames('button', className, { className={classNames('button', className, {
'button-secondary': secondary, 'button-secondary': secondary,
'button--block': block, 'button--block': block,
'button--dangerous': dangerous,
})} })}
disabled={disabled} disabled={disabled}
onClick={handleClick} onClick={handleClick}

View file

@ -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>
);

View 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>
);

View file

@ -10,7 +10,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { AltTextBadge } from 'flavours/glitch/components/alt_text_badge';
import { Blurhash } from 'flavours/glitch/components/blurhash'; import { Blurhash } from 'flavours/glitch/components/blurhash';
import { formatTime } from 'flavours/glitch/features/video';
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state'; import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
@ -58,7 +60,7 @@ class Item extends PureComponent {
hoverToPlay () { hoverToPlay () {
const { attachment } = this.props; const { attachment } = this.props;
return !this.getAutoPlay() && attachment.get('type') === 'gifv'; return !this.getAutoPlay() && ['gifv', 'video'].includes(attachment.get('type'));
} }
handleClick = (e) => { handleClick = (e) => {
@ -97,7 +99,7 @@ class Item extends PureComponent {
} }
if (attachment.get('description')?.length > 0) { if (attachment.get('description')?.length > 0) {
badges.push(<span key='alt' className='media-gallery__alt__label'>ALT</span>); badges.push(<AltTextBadge key='alt' description={attachment.get('description')} />);
} }
const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
@ -152,10 +154,15 @@ class Item extends PureComponent {
/> />
</a> </a>
); );
} else if (attachment.get('type') === 'gifv') { } else if (['gifv', 'video'].includes(attachment.get('type'))) {
const autoPlay = this.getAutoPlay(); const autoPlay = this.getAutoPlay();
const duration = attachment.getIn(['meta', 'original', 'duration']);
badges.push(<span key='gif' className='media-gallery__gifv__label'>GIF</span>); if (attachment.get('type') === 'gifv') {
badges.push(<span key='gif' className='media-gallery__alt__label media-gallery__alt__label--non-interactive'>GIF</span>);
} else {
badges.push(<span key='video' className='media-gallery__alt__label media-gallery__alt__label--non-interactive'>{formatTime(Math.floor(duration))}</span>);
}
thumbnail = ( thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
@ -169,6 +176,7 @@ class Item extends PureComponent {
onClick={this.handleClick} onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter} onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave} onMouseLeave={this.handleMouseLeave}
onLoadedData={this.handleImageLoad}
autoPlay={autoPlay} autoPlay={autoPlay}
playsInline playsInline
loop loop

View file

@ -153,7 +153,7 @@ class ModalRoot extends PureComponent {
return ( return (
<div className='modal-root' ref={this.setRef}> <div className='modal-root' ref={this.setRef}>
<div style={{ pointerEvents: visible ? 'auto' : 'none' }}> <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.7)` : null }} /> <div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.9)` : null }} />
<div role='dialog' className='modal-root__container'>{children}</div> <div role='dialog' className='modal-root__container'>{children}</div>
</div> </div>
</div> </div>

View file

@ -4,22 +4,22 @@ import AccountNavigation from 'flavours/glitch/features/account/navigation';
import Trends from 'flavours/glitch/features/getting_started/containers/trends_container'; import Trends from 'flavours/glitch/features/getting_started/containers/trends_container';
import { showTrends } from 'flavours/glitch/initial_state'; import { showTrends } from 'flavours/glitch/initial_state';
const DefaultNavigation: React.FC = () => const DefaultNavigation: React.FC = () => (showTrends ? <Trends /> : null);
showTrends ? (
<>
<div className='flex-spacer' />
<Trends />
</>
) : null;
export const NavigationPortal: React.FC = () => ( export const NavigationPortal: React.FC = () => (
<div className='navigation-panel__portal'>
<Switch> <Switch>
<Route path='/@:acct' exact component={AccountNavigation} /> <Route path='/@:acct' exact component={AccountNavigation} />
<Route path='/@:acct/tagged/:tagged?' exact component={AccountNavigation} /> <Route
path='/@:acct/tagged/:tagged?'
exact
component={AccountNavigation}
/>
<Route path='/@:acct/with_replies' exact component={AccountNavigation} /> <Route path='/@:acct/with_replies' exact component={AccountNavigation} />
<Route path='/@:acct/followers' exact component={AccountNavigation} /> <Route path='/@:acct/followers' exact component={AccountNavigation} />
<Route path='/@:acct/following' exact component={AccountNavigation} /> <Route path='/@:acct/following' exact component={AccountNavigation} />
<Route path='/@:acct/media' exact component={AccountNavigation} /> <Route path='/@:acct/media' exact component={AccountNavigation} />
<Route component={DefaultNavigation} /> <Route component={DefaultNavigation} />
</Switch> </Switch>
</div>
); );

View file

@ -51,7 +51,8 @@ function normalizePath(
if ( if (
layoutFromWindow() === 'multi-column' && layoutFromWindow() === 'multi-column' &&
!location.pathname?.startsWith('/deck') location.pathname &&
!location.pathname.startsWith('/deck')
) { ) {
location.pathname = `/deck${location.pathname}`; location.pathname = `/deck${location.pathname}`;
} }

View file

@ -654,6 +654,27 @@ class Status extends ImmutablePureComponent {
media={status.get('media_attachments')} media={status.get('media_attachments')}
/>, />,
); );
} else if (['image', 'gifv'].includes(status.getIn(['media_attachments', 0, 'type'])) || status.get('media_attachments').size > 1) {
media.push(
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => (
<Component
media={attachments}
lang={language}
sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={!rootId && settings.getIn(['media', 'fullwidth'])}
hidden={isCollapsed || !isExpanded}
onOpenMedia={this.handleOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>,
);
mediaIcons.push('picture-o');
} else if (attachments.getIn([0, 'type']) === 'audio') { } else if (attachments.getIn([0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]); const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
@ -709,27 +730,6 @@ class Status extends ImmutablePureComponent {
</Bundle>, </Bundle>,
); );
mediaIcons.push('video-camera'); mediaIcons.push('video-camera');
} else { // Media type is 'image' or 'gifv'
media.push(
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => (
<Component
media={attachments}
lang={language}
sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={!rootId && settings.getIn(['media', 'fullwidth'])}
hidden={isCollapsed || !isExpanded}
onOpenMedia={this.handleOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>,
);
mediaIcons.push('picture-o');
} }
if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) { if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {

View file

@ -323,13 +323,16 @@ class StatusActionBar extends ImmutablePureComponent {
} }
const filterButton = this.props.onFilter && ( const filterButton = this.props.onFilter && (
<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} /> <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' iconComponent={VisibilityIcon} onClick={this.handleHideClick} />
</div>
); );
const canReact = permissions && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; const canReact = permissions && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
return ( return (
<div className='status__action-bar'> <div className='status__action-bar'>
<div className='status__action-bar__button-wrapper'>
<IconButton <IconButton
className='status__action-bar-button' className='status__action-bar-button'
title={replyTitle} title={replyTitle}
@ -339,13 +342,34 @@ class StatusActionBar extends ImmutablePureComponent {
counter={showReplyCount ? status.get('replies_count') : undefined} counter={showReplyCount ? status.get('replies_count') : undefined}
obfuscateCount 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} /> </div>
<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 className='status__action-bar__button-wrapper'>
<EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} title={intl.formatMessage(messages.react)} icon={AddReactionIcon} disabled={!canReact} /> <IconButton className={classNames('status__action-bar-button', { reblogPrivate })}
<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} /> 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'>
<EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick}
title={intl.formatMessage(messages.react)} icon={AddReactionIcon} disabled={!canReact} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn}
active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark'
iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon}
onClick={this.handleBookmarkClick} />
</div>
{filterButton} {filterButton}
<div className='status__action-bar__button-wrapper'>
<DropdownMenuContainer <DropdownMenuContainer
scrollKey={scrollKey} scrollKey={scrollKey}
status={status} status={status}
@ -356,10 +380,20 @@ class StatusActionBar extends ImmutablePureComponent {
direction='right' direction='right'
ariaLabel={intl.formatMessage(messages.more)} ariaLabel={intl.formatMessage(messages.more)}
/> />
</div>
<div className='status__action-bar-spacer' /> <div className='status__action-bar-spacer' />
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'> <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>} <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr
title={intl.formatMessage(messages.edited, {
date: intl.formatDate(status.get('edited_at'), {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
})}> *</abbr>}
</a> </a>
</div> </div>
); );

View 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>
);

View file

@ -14,6 +14,7 @@ import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
import LinkIcon from '@/material-icons/400-24px/link.svg?react'; import LinkIcon from '@/material-icons/400-24px/link.svg?react';
import MovieIcon from '@/material-icons/400-24px/movie.svg?react'; import MovieIcon from '@/material-icons/400-24px/movie.svg?react';
import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react'; import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react';
import { ContentWarning } from 'flavours/glitch/components/content_warning';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
import { autoPlayGif, languages as preloadedLanguages } from 'flavours/glitch/initial_state'; import { autoPlayGif, languages as preloadedLanguages } from 'flavours/glitch/initial_state';
@ -350,7 +351,7 @@ class StatusContent extends PureComponent {
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale); const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
const content = { __html: statusContent ?? getStatusContent(status) }; const content = { __html: statusContent ?? getStatusContent(status) };
const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') }; const spoilerHtml = status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml');
const language = status.getIn(['translation', 'language']) || status.get('language'); const language = status.getIn(['translation', 'language']) || status.get('language');
const classNames = classnames('status__content', { const classNames = classnames('status__content', {
'status__content--with-action': parseClick && !disabled, 'status__content--with-action': parseClick && !disabled,
@ -375,16 +376,8 @@ class StatusContent extends PureComponent {
</Permalink> </Permalink>
)).reduce((aggregate, item) => [...aggregate, item, ' '], []); )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
let toggleText = null; let spoilerIcons = [];
if (hidden) { if (hidden && mediaIcons) {
toggleText = [
<FormattedMessage
id='status.show_more'
defaultMessage='Show more'
key='0'
/>,
];
if (mediaIcons) {
const mediaComponents = { const mediaComponents = {
'link': LinkIcon, 'link': LinkIcon,
'picture-o': ImageIcon, 'picture-o': ImageIcon,
@ -393,27 +386,16 @@ class StatusContent extends PureComponent {
'music': MusicNoteIcon, 'music': MusicNoteIcon,
}; };
mediaIcons.forEach((mediaIcon, idx) => { spoilerIcons = mediaIcons.map((mediaIcon) => (
toggleText.push(
<Icon <Icon
fixedWidth fixedWidth
className='status__content__spoiler-icon' className='status__content__spoiler-icon'
id={mediaIcon} id={mediaIcon}
icon={mediaComponents[mediaIcon]} icon={mediaComponents[mediaIcon]}
aria-hidden='true' aria-hidden='true'
key={`icon-${idx}`} key={`icon-${mediaIcon}`}
/>,
);
});
}
} else {
toggleText = (
<FormattedMessage
id='status.show_less'
defaultMessage='Show less'
key='0'
/> />
); ));
} }
if (hidden) { if (hidden) {
@ -422,15 +404,7 @@ class StatusContent extends PureComponent {
return ( return (
<div className={classNames} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> <div className={classNames} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<p <ContentWarning text={spoilerHtml} expanded={!hidden} onClick={this.handleSpoilerClick} icons={spoilerIcons} />
style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
>
<span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={language} />
{' '}
<button type='button' className='status__content__spoiler-link' onClick={this.handleSpoilerClick} aria-expanded={!hidden}>
{toggleText}
</button>
</p>
{mentionsPlaceholder} {mentionsPlaceholder}

View file

@ -43,10 +43,7 @@ class AccountNavigation extends PureComponent {
} }
return ( return (
<>
<div className='flex-spacer' />
<FeaturedTags accountId={accountId} tagged={tagged} /> <FeaturedTags accountId={accountId} tagged={tagged} />
</>
); );
} }

View file

@ -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>
);
}
}

View file

@ -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>
);
};

View file

@ -20,7 +20,7 @@ import { expandAccountMediaTimeline } from '../../actions/timelines';
import HeaderContainer from '../account_timeline/containers/header_container'; import HeaderContainer from '../account_timeline/containers/header_container';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
import MediaItem from './components/media_item'; import { MediaItem } from './components/media_item';
const mapStateToProps = (state, { params: { acct, id } }) => { const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]); const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);

View file

@ -21,11 +21,11 @@ const messages = defineMessages({
export const SensitiveButton = () => { export const SensitiveButton = () => {
const intl = useIntl(); const intl = useIntl();
const spoilersAlwaysOn = useAppSelector((state) => state.getIn(['local_settings', 'always_show_spoilers_field'])); const spoilersAlwaysOn = useAppSelector((state) => state.local_settings.getIn(['always_show_spoilers_field']));
const spoilerText = useAppSelector((state) => state.getIn(['compose', 'spoiler_text'])); const spoilerText = useAppSelector((state) => state.compose.get('spoiler_text'));
const sensitive = useAppSelector((state) => state.getIn(['compose', 'sensitive'])); const sensitive = useAppSelector((state) => state.compose.get('sensitive'));
const spoiler = useAppSelector((state) => state.getIn(['compose', 'spoiler'])); const spoiler = useAppSelector((state) => state.compose.get('spoiler'));
const mediaCount = useAppSelector((state) => state.getIn(['compose', 'media_attachments']).size); const mediaCount = useAppSelector((state) => state.compose.get('media_attachments').size);
const disabled = spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler; const disabled = spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler;
const active = sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0); const active = sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0);

View file

@ -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,
};

View file

@ -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>
);
};

View file

@ -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 />}
</>
);
};

View file

@ -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 />}
</>
);
};

View file

@ -1,5 +1,5 @@
import type { ChangeEventHandler } from 'react'; import type { ChangeEventHandler } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
@ -26,6 +26,8 @@ import { RadioButton } from 'flavours/glitch/components/radio_button';
import ScrollContainer from 'flavours/glitch/containers/scroll_container'; import ScrollContainer from 'flavours/glitch/containers/scroll_container';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import { useSearchParam } from '../../hooks/useSearchParam';
import { AccountCard } from './components/account_card'; import { AccountCard } from './components/account_card';
const messages = defineMessages({ const messages = defineMessages({
@ -50,18 +52,19 @@ export const Directory: React.FC<{
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [state, setState] = useState<{
order: string | null;
local: boolean | null;
}>({
order: null,
local: null,
});
const column = useRef<Column>(null); const column = useRef<Column>(null);
const order = state.order ?? params?.order ?? 'active'; const [orderParam, setOrderParam] = useSearchParam('order');
const local = state.local ?? params?.local ?? false; const [localParam, setLocalParam] = useSearchParam('local');
let localParamBool: boolean | undefined;
if (localParam === 'false') {
localParamBool = false;
}
const order = orderParam ?? params?.order ?? 'active';
const local = localParamBool ?? params?.local ?? true;
const handlePin = useCallback(() => { const handlePin = useCallback(() => {
if (columnId) { if (columnId) {
@ -104,10 +107,10 @@ export const Directory: React.FC<{
if (columnId) { if (columnId) {
dispatch(changeColumnParams(columnId, ['order'], e.target.value)); dispatch(changeColumnParams(columnId, ['order'], e.target.value));
} else { } else {
setState((s) => ({ order: e.target.value, local: s.local })); setOrderParam(e.target.value);
} }
}, },
[dispatch, columnId], [dispatch, columnId, setOrderParam],
); );
const handleChangeLocal = useCallback<ChangeEventHandler<HTMLInputElement>>( const handleChangeLocal = useCallback<ChangeEventHandler<HTMLInputElement>>(
@ -116,11 +119,13 @@ export const Directory: React.FC<{
dispatch( dispatch(
changeColumnParams(columnId, ['local'], e.target.value === '1'), changeColumnParams(columnId, ['local'], e.target.value === '1'),
); );
} else if (e.target.value === '1') {
setLocalParam('true');
} else { } else {
setState((s) => ({ local: e.target.value === '1', order: s.order })); setLocalParam('false');
} }
}, },
[dispatch, columnId], [dispatch, columnId, setLocalParam],
); );
const handleLoadMore = useCallback(() => { const handleLoadMore = useCallback(() => {

View file

@ -31,7 +31,7 @@ export const FilteredNotificationsIconButton: React.FC<{
history.push('/notifications/requests'); history.push('/notifications/requests');
}, [history]); }, [history]);
if (policy === null || policy.summary.pending_notifications_count === 0) { if (policy === null || policy.summary.pending_requests_count <= 0) {
return null; return null;
} }
@ -70,7 +70,7 @@ export const FilteredNotificationsBanner: React.FC = () => {
}; };
}, [dispatch]); }, [dispatch]);
if (policy === null || policy.summary.pending_notifications_count === 0) { if (policy === null || policy.summary.pending_requests_count <= 0) {
return null; return null;
} }

View file

@ -8,11 +8,13 @@ import type { List as ImmutableList, RecordOf } from 'immutable';
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react'; import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'; import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { toggleStatusSpoilers } from 'flavours/glitch/actions/statuses';
import { Avatar } from 'flavours/glitch/components/avatar'; import { Avatar } from 'flavours/glitch/components/avatar';
import { ContentWarning } from 'flavours/glitch/components/content_warning';
import { DisplayName } from 'flavours/glitch/components/display_name'; import { DisplayName } from 'flavours/glitch/components/display_name';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import type { Status } from 'flavours/glitch/models/status'; import type { Status } from 'flavours/glitch/models/status';
import { useAppSelector } from 'flavours/glitch/store'; import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { EmbeddedStatusContent } from './embedded_status_content'; import { EmbeddedStatusContent } from './embedded_status_content';
@ -23,6 +25,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
}) => { }) => {
const history = useHistory(); const history = useHistory();
const clickCoordinatesRef = useRef<[number, number] | null>(); const clickCoordinatesRef = useRef<[number, number] | null>();
const dispatch = useAppDispatch();
const status = useAppSelector( const status = useAppSelector(
(state) => state.statuses.get(statusId) as Status | undefined, (state) => state.statuses.get(statusId) as Status | undefined,
@ -96,15 +99,21 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
[], [],
); );
const handleContentWarningClick = useCallback(() => {
dispatch(toggleStatusSpoilers(statusId));
}, [dispatch, statusId]);
if (!status) { if (!status) {
return null; return null;
} }
// Assign status attributes to variables with a forced type, as status is not yet properly typed // Assign status attributes to variables with a forced type, as status is not yet properly typed
const contentHtml = status.get('contentHtml') as string; const contentHtml = status.get('contentHtml') as string;
const contentWarning = status.get('spoilerHtml') as string;
const poll = status.get('poll'); const poll = status.get('poll');
const language = status.get('language') as string; const language = status.get('language') as string;
const mentions = status.get('mentions') as ImmutableList<Mention>; const mentions = status.get('mentions') as ImmutableList<Mention>;
const expanded = !status.get('hidden') || !contentWarning;
const mediaAttachmentsSize = ( const mediaAttachmentsSize = (
status.get('media_attachments') as ImmutableList<unknown> status.get('media_attachments') as ImmutableList<unknown>
).size; ).size;
@ -124,14 +133,24 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
<DisplayName account={account} /> <DisplayName account={account} />
</div> </div>
{contentWarning && (
<ContentWarning
text={contentWarning}
onClick={handleContentWarningClick}
expanded={expanded}
/>
)}
{(!contentWarning || expanded) && (
<EmbeddedStatusContent <EmbeddedStatusContent
className='notification-group__embedded-status__content reply-indicator__content translate' className='notification-group__embedded-status__content reply-indicator__content translate'
content={contentHtml} content={contentHtml}
language={language} language={language}
mentions={mentions} mentions={mentions}
/> />
)}
{(poll || mediaAttachmentsSize > 0) && ( {expanded && (poll || mediaAttachmentsSize > 0) && (
<div className='notification-group__embedded-status__attachments reply-indicator__attachments'> <div className='notification-group__embedded-status__attachments reply-indicator__attachments'>
{!!poll && ( {!!poll && (
<> <>

View file

@ -21,6 +21,7 @@ import { Permalink } from 'flavours/glitch/components/permalink';
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
import { useAppHistory } from 'flavours/glitch/components/router'; import { useAppHistory } from 'flavours/glitch/components/router';
import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon'; import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon';
import PollContainer from 'flavours/glitch/containers/poll_container';
import { useIdentity } from 'flavours/glitch/identity_context'; import { useIdentity } from 'flavours/glitch/identity_context';
import { useAppSelector } from 'flavours/glitch/store'; import { useAppSelector } from 'flavours/glitch/store';
@ -202,6 +203,28 @@ export const DetailedStatus: React.FC<{
) )
) { ) {
media.push(<AttachmentList media={status.get('media_attachments')} />); media.push(<AttachmentList media={status.get('media_attachments')} />);
} else if (
['image', 'gifv'].includes(
status.getIn(['media_attachments', 0, 'type']) as string,
) ||
status.get('media_attachments').size > 1
) {
media.push(
<MediaGallery
standalone
sensitive={status.get('sensitive')}
media={status.get('media_attachments')}
lang={language}
height={300}
letterbox={letterboxMedia}
fullwidth={fullwidthMedia}
hidden={!expanded}
onOpenMedia={onOpenMedia}
visible={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>,
);
mediaIcons.push('picture-o');
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]); const attachment = status.getIn(['media_attachments', 0]);
const description = const description =
@ -256,23 +279,6 @@ export const DetailedStatus: React.FC<{
/>, />,
); );
mediaIcons.push('video-camera'); mediaIcons.push('video-camera');
} else {
media.push(
<MediaGallery
standalone
sensitive={status.get('sensitive')}
media={status.get('media_attachments')}
lang={language}
height={300}
letterbox={letterboxMedia}
fullwidth={fullwidthMedia}
hidden={!expanded}
onOpenMedia={onOpenMedia}
visible={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>,
);
mediaIcons.push('picture-o');
} }
} else if (status.get('spoiler_text').length === 0) { } else if (status.get('spoiler_text').length === 0) {
media.push( media.push(
@ -285,6 +291,17 @@ export const DetailedStatus: React.FC<{
mediaIcons.push('link'); mediaIcons.push('link');
} }
if (status.get('poll')) {
contentMedia.push(
<PollContainer
pollId={status.get('poll')}
// @ts-expect-error -- Poll/PollContainer is not typed yet
lang={status.get('language')}
/>,
);
contentMediaIcons.push('tasks');
}
if (status.get('application')) { if (status.get('application')) {
applicationLink = ( applicationLink = (
<> <>

View file

@ -99,7 +99,7 @@ export const BlockModal = ({ accountId, acct }) => {
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
</button> </button>
<Button onClick={handleClick} autoFocus> <Button onClick={handleClick} dangerous autoFocus>
<FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' /> <FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' />
</Button> </Button>
</div> </div>

View file

@ -5,9 +5,9 @@ import { useRouteMatch, NavLink } from 'react-router-dom';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
const ColumnLink = ({ icon, activeIcon, iconComponent, activeIconComponent, text, to, onClick, href, method, badge, transparent, ...other }) => { const ColumnLink = ({ icon, activeIcon, iconComponent, activeIconComponent, text, to, onClick, href, method, badge, transparent, optional, ...other }) => {
const match = useRouteMatch(to); const match = useRouteMatch(to);
const className = classNames('column-link', { 'column-link--transparent': transparent }); const className = classNames('column-link', { 'column-link--transparent': transparent, 'column-link--optional': optional });
const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null; const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null;
const iconElement = (typeof icon === 'string' || iconComponent) ? <Icon id={icon} icon={iconComponent} className='column-link__icon' /> : icon; const iconElement = (typeof icon === 'string' || iconComponent) ? <Icon id={icon} icon={iconComponent} className='column-link__icon' /> : icon;
const activeIconElement = activeIcon ?? (activeIconComponent ? <Icon id={icon} icon={activeIconComponent} className='column-link__icon' /> : iconElement); const activeIconElement = activeIcon ?? (activeIconComponent ? <Icon id={icon} icon={activeIconComponent} className='column-link__icon' /> : iconElement);
@ -58,6 +58,7 @@ ColumnLink.propTypes = {
method: PropTypes.string, method: PropTypes.string,
badge: PropTypes.node, badge: PropTypes.node,
transparent: PropTypes.bool, transparent: PropTypes.bool,
optional: PropTypes.bool,
}; };
export default ColumnLink; export default ColumnLink;

View file

@ -4,8 +4,6 @@ import { Children, cloneElement, useCallback } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { supportsPassiveEvents } from 'detect-passive-events';
import { scrollRight } from '../../../scroll'; import { scrollRight } from '../../../scroll';
import BundleContainer from '../containers/bundle_container'; import BundleContainer from '../containers/bundle_container';
import { import {
@ -65,17 +63,13 @@ export default class ColumnsArea extends ImmutablePureComponent {
}; };
// Corresponds to (max-width: $no-gap-breakpoint - 1px) in SCSS // Corresponds to (max-width: $no-gap-breakpoint - 1px) in SCSS
mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1206px)'); mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1174px)');
state = { state = {
renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches), renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
}; };
componentDidMount() { componentDidMount() {
if (!this.props.singleColumn) {
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
}
if (this.mediaQuery) { if (this.mediaQuery) {
if (this.mediaQuery.addEventListener) { if (this.mediaQuery.addEventListener) {
this.mediaQuery.addEventListener('change', this.handleLayoutChange); this.mediaQuery.addEventListener('change', this.handleLayoutChange);
@ -88,23 +82,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl'); this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
} }
UNSAFE_componentWillUpdate(nextProps) {
if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
this.node.removeEventListener('wheel', this.handleWheel);
}
}
componentDidUpdate(prevProps) {
if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
}
}
componentWillUnmount () { componentWillUnmount () {
if (!this.props.singleColumn) {
this.node.removeEventListener('wheel', this.handleWheel);
}
if (this.mediaQuery) { if (this.mediaQuery) {
if (this.mediaQuery.removeEventListener) { if (this.mediaQuery.removeEventListener) {
this.mediaQuery.removeEventListener('change', this.handleLayoutChange); this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
@ -117,7 +95,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
handleChildrenContentChange() { handleChildrenContentChange() {
if (!this.props.singleColumn) { if (!this.props.singleColumn) {
const modifier = this.isRtlLayout ? -1 : 1; const modifier = this.isRtlLayout ? -1 : 1;
this._interruptScrollAnimation = scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier); scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier);
} }
} }
@ -125,14 +103,6 @@ export default class ColumnsArea extends ImmutablePureComponent {
this.setState({ renderComposePanel: !e.matches }); this.setState({ renderComposePanel: !e.matches });
}; };
handleWheel = () => {
if (typeof this._interruptScrollAnimation !== 'function') {
return;
}
this._interruptScrollAnimation();
};
setRef = (node) => { setRef = (node) => {
this.node = node; this.node = node;
}; };

View file

@ -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;

View file

@ -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;

View file

@ -17,7 +17,7 @@ export default class ImageLoader extends PureComponent {
width: PropTypes.number, width: PropTypes.number,
height: PropTypes.number, height: PropTypes.number,
onClick: PropTypes.func, onClick: PropTypes.func,
zoomButtonHidden: PropTypes.bool, zoomedIn: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -134,7 +134,7 @@ export default class ImageLoader extends PureComponent {
}; };
render () { render () {
const { alt, lang, src, width, height, onClick } = this.props; const { alt, lang, src, width, height, onClick, zoomedIn } = this.props;
const { loading } = this.state; const { loading } = this.state;
const className = classNames('image-loader', { const className = classNames('image-loader', {
@ -149,6 +149,7 @@ export default class ImageLoader extends PureComponent {
<div className='loading-bar__container' style={{ width: this.state.width || width }}> <div className='loading-bar__container' style={{ width: this.state.width || width }}>
<LoadingBar className='loading-bar' loading={1} /> <LoadingBar className='loading-bar' loading={1} />
</div> </div>
<canvas <canvas
className='image-loader__preview-canvas' className='image-loader__preview-canvas'
ref={this.setCanvasRef} ref={this.setCanvasRef}
@ -164,7 +165,7 @@ export default class ImageLoader extends PureComponent {
onClick={onClick} onClick={onClick}
width={width} width={width}
height={height} height={height}
zoomButtonHidden={this.props.zoomButtonHidden} zoomedIn={zoomedIn}
/> />
)} )}
</div> </div>

View file

@ -12,6 +12,8 @@ import ReactSwipeableViews from 'react-swipeable-views';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import FitScreenIcon from '@/material-icons/400-24px/fit_screen.svg?react';
import ActualSizeIcon from '@/svg-icons/actual_size.svg?react';
import { getAverageFromBlurhash } from 'flavours/glitch/blurhash'; import { getAverageFromBlurhash } from 'flavours/glitch/blurhash';
import { GIFV } from 'flavours/glitch/components/gifv'; import { GIFV } from 'flavours/glitch/components/gifv';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
@ -26,6 +28,8 @@ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }, close: { id: 'lightbox.close', defaultMessage: 'Close' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' }, next: { id: 'lightbox.next', defaultMessage: 'Next' },
zoomIn: { id: 'lightbox.zoom_in', defaultMessage: 'Zoom to actual size' },
zoomOut: { id: 'lightbox.zoom_out', defaultMessage: 'Zoom to fit' },
}); });
class MediaModal extends ImmutablePureComponent { class MediaModal extends ImmutablePureComponent {
@ -46,30 +50,39 @@ class MediaModal extends ImmutablePureComponent {
state = { state = {
index: null, index: null,
navigationHidden: false, navigationHidden: false,
zoomButtonHidden: false, zoomedIn: false,
};
handleZoomClick = () => {
this.setState(prevState => ({
zoomedIn: !prevState.zoomedIn,
}));
}; };
handleSwipe = (index) => { handleSwipe = (index) => {
this.setState({ index: index % this.props.media.size }); this.setState({
index: index % this.props.media.size,
zoomedIn: false,
});
}; };
handleTransitionEnd = () => { handleTransitionEnd = () => {
this.setState({ this.setState({
zoomButtonHidden: false, zoomedIn: false,
}); });
}; };
handleNextClick = () => { handleNextClick = () => {
this.setState({ this.setState({
index: (this.getIndex() + 1) % this.props.media.size, index: (this.getIndex() + 1) % this.props.media.size,
zoomButtonHidden: true, zoomedIn: false,
}); });
}; };
handlePrevClick = () => { handlePrevClick = () => {
this.setState({ this.setState({
index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size, index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
zoomButtonHidden: true, zoomedIn: false,
}); });
}; };
@ -78,7 +91,7 @@ class MediaModal extends ImmutablePureComponent {
this.setState({ this.setState({
index: index % this.props.media.size, index: index % this.props.media.size,
zoomButtonHidden: true, zoomedIn: false,
}); });
}; };
@ -130,15 +143,22 @@ class MediaModal extends ImmutablePureComponent {
return this.state.index !== null ? this.state.index : this.props.index; return this.state.index !== null ? this.state.index : this.props.index;
} }
toggleNavigation = () => { handleToggleNavigation = () => {
this.setState(prevState => ({ this.setState(prevState => ({
navigationHidden: !prevState.navigationHidden, navigationHidden: !prevState.navigationHidden,
})); }));
}; };
setRef = c => {
this.setState({
viewportWidth: c?.clientWidth,
viewportHeight: c?.clientHeight,
});
};
render () { render () {
const { media, statusId, lang, intl, onClose } = this.props; const { media, statusId, lang, intl, onClose } = this.props;
const { navigationHidden } = this.state; const { navigationHidden, zoomedIn, viewportWidth, viewportHeight } = this.state;
const index = this.getIndex(); const index = this.getIndex();
@ -160,8 +180,8 @@ class MediaModal extends ImmutablePureComponent {
alt={description} alt={description}
lang={lang} lang={lang}
key={image.get('url')} key={image.get('url')}
onClick={this.toggleNavigation} onClick={this.handleToggleNavigation}
zoomButtonHidden={this.state.zoomButtonHidden} zoomedIn={zoomedIn}
/> />
); );
} else if (image.get('type') === 'video') { } else if (image.get('type') === 'video') {
@ -229,8 +249,11 @@ class MediaModal extends ImmutablePureComponent {
)); ));
} }
const currentMedia = media.get(index);
const zoomable = currentMedia.get('type') === 'image' && (currentMedia.getIn(['meta', 'original', 'width']) > viewportWidth || currentMedia.getIn(['meta', 'original', 'height']) > viewportHeight);
return ( return (
<div className='modal-root__modal media-modal'> <div className='modal-root__modal media-modal' ref={this.setRef}>
<div className='media-modal__closer' role='presentation' onClick={onClose}> <div className='media-modal__closer' role='presentation' onClick={onClose}>
<ReactSwipeableViews <ReactSwipeableViews
style={swipeableViewsStyle} style={swipeableViewsStyle}
@ -245,7 +268,10 @@ class MediaModal extends ImmutablePureComponent {
</div> </div>
<div className={navigationClassName}> <div className={navigationClassName}>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={40} /> <div className='media-modal__buttons'>
{zoomable && <IconButton title={intl.formatMessage(zoomedIn ? messages.zoomOut : messages.zoomIn)} iconComponent={zoomedIn ? FitScreenIcon : ActualSizeIcon} onClick={this.handleZoomClick} />}
<IconButton title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} />
</div>
{leftNav} {leftNav}
{rightNav} {rightNav}

View file

@ -122,14 +122,17 @@ class NavigationPanel extends Component {
let banner = undefined; let banner = undefined;
if(transientSingleColumn) if (transientSingleColumn) {
banner = (<div className='switch-to-advanced'> banner = (
<div className='switch-to-advanced'>
{intl.formatMessage(messages.openedInClassicInterface)} {intl.formatMessage(messages.openedInClassicInterface)}
{" "} {" "}
<a href={`/deck${location.pathname}`} className='switch-to-advanced__toggle'> <a href={`/deck${location.pathname}`} className='switch-to-advanced__toggle'>
{intl.formatMessage(messages.advancedInterface)} {intl.formatMessage(messages.advancedInterface)}
</a> </a>
</div>); </div>
);
}
return ( return (
<div className='navigation-panel'> <div className='navigation-panel'>
@ -139,6 +142,7 @@ class NavigationPanel extends Component {
</div> </div>
} }
<div className='navigation-panel__menu'>
{signedIn && ( {signedIn && (
<> <>
<ColumnLink transparent to='/home' icon='home' iconComponent={HomeIcon} activeIconComponent={HomeActiveIcon} text={intl.formatMessage(messages.home)} /> <ColumnLink transparent to='/home' icon='home' iconComponent={HomeIcon} activeIconComponent={HomeActiveIcon} text={intl.formatMessage(messages.home)} />
@ -178,8 +182,8 @@ class NavigationPanel extends Component {
{!!preferencesLink && <ColumnLink transparent href={preferencesLink} icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} />} {!!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)} /> <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)} />} {canManageReports(permissions) && <ColumnLink optional 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)} />} {canViewAdminDashboard(permissions) && <ColumnLink optional transparent href='/admin/dashboard' icon='tachometer' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.administration)} />}
</> </>
)} )}
@ -187,6 +191,9 @@ class NavigationPanel extends Component {
<hr /> <hr />
<ColumnLink transparent to='/about' icon='ellipsis-h' iconComponent={MoreHorizIcon} text={intl.formatMessage(messages.about)} /> <ColumnLink transparent to='/about' icon='ellipsis-h' iconComponent={MoreHorizIcon} text={intl.formatMessage(messages.about)} />
</div> </div>
</div>
<div className='flex-spacer' />
<NavigationPortal /> <NavigationPortal />
</div> </div>

View file

@ -1,17 +1,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import FullscreenExitIcon from '@/material-icons/400-24px/fullscreen_exit.svg?react';
import RectangleIcon from '@/material-icons/400-24px/rectangle.svg?react';
import { IconButton } from 'flavours/glitch/components/icon_button';
const messages = defineMessages({
compress: { id: 'lightbox.compress', defaultMessage: 'Compress image view box' },
expand: { id: 'lightbox.expand', defaultMessage: 'Expand image view box' },
});
const MIN_SCALE = 1; const MIN_SCALE = 1;
const MAX_SCALE = 4; const MAX_SCALE = 4;
const NAV_BAR_HEIGHT = 66; const NAV_BAR_HEIGHT = 66;
@ -104,8 +93,7 @@ class ZoomableImage extends PureComponent {
width: PropTypes.number, width: PropTypes.number,
height: PropTypes.number, height: PropTypes.number,
onClick: PropTypes.func, onClick: PropTypes.func,
zoomButtonHidden: PropTypes.bool, zoomedIn: PropTypes.bool,
intl: PropTypes.object.isRequired,
}; };
static defaultProps = { static defaultProps = {
@ -131,8 +119,6 @@ class ZoomableImage extends PureComponent {
translateX: null, translateX: null,
translateY: null, translateY: null,
}, },
zoomState: 'expand', // 'expand' 'compress'
navigationHidden: false,
dragPosition: { top: 0, left: 0, x: 0, y: 0 }, dragPosition: { top: 0, left: 0, x: 0, y: 0 },
dragged: false, dragged: false,
lockScroll: { x: 0, y: 0 }, lockScroll: { x: 0, y: 0 },
@ -169,35 +155,20 @@ class ZoomableImage extends PureComponent {
this.container.addEventListener('DOMMouseScroll', handler); this.container.addEventListener('DOMMouseScroll', handler);
this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler)); this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
this.initZoomMatrix(); this._initZoomMatrix();
} }
componentWillUnmount () { componentWillUnmount () {
this.removeEventListeners(); this._removeEventListeners();
} }
componentDidUpdate () { componentDidUpdate (prevProps) {
this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' }); if (prevProps.zoomedIn !== this.props.zoomedIn) {
this._toggleZoom();
if (this.state.scale === MIN_SCALE) {
this.container.style.removeProperty('cursor');
} }
} }
UNSAFE_componentWillReceiveProps () { _removeEventListeners () {
// reset when slide to next image
if (this.props.zoomButtonHidden) {
this.setState({
scale: MIN_SCALE,
lockTranslate: { x: 0, y: 0 },
}, () => {
this.container.scrollLeft = 0;
this.container.scrollTop = 0;
});
}
}
removeEventListeners () {
this.removers.forEach(listeners => listeners()); this.removers.forEach(listeners => listeners());
this.removers = []; this.removers = [];
} }
@ -220,9 +191,6 @@ class ZoomableImage extends PureComponent {
}; };
mouseDownHandler = e => { mouseDownHandler = e => {
this.container.style.cursor = 'grabbing';
this.container.style.userSelect = 'none';
this.setState({ dragPosition: { this.setState({ dragPosition: {
left: this.container.scrollLeft, left: this.container.scrollLeft,
top: this.container.scrollTop, top: this.container.scrollTop,
@ -246,9 +214,6 @@ class ZoomableImage extends PureComponent {
}; };
mouseUpHandler = () => { mouseUpHandler = () => {
this.container.style.cursor = 'grab';
this.container.style.removeProperty('user-select');
this.image.removeEventListener('mousemove', this.mouseMoveHandler); this.image.removeEventListener('mousemove', this.mouseMoveHandler);
this.image.removeEventListener('mouseup', this.mouseUpHandler); this.image.removeEventListener('mouseup', this.mouseUpHandler);
}; };
@ -276,13 +241,13 @@ class ZoomableImage extends PureComponent {
const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate); const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance); const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
this.zoom(scale, midpoint); this._zoom(scale, midpoint);
this.lastMidpoint = midpoint; this.lastMidpoint = midpoint;
this.lastDistance = distance; this.lastDistance = distance;
}; };
zoom(nextScale, midpoint) { _zoom(nextScale, midpoint) {
const { scale, zoomMatrix } = this.state; const { scale, zoomMatrix } = this.state;
const { scrollLeft, scrollTop } = this.container; const { scrollLeft, scrollTop } = this.container;
@ -318,14 +283,13 @@ class ZoomableImage extends PureComponent {
if (dragged) return; if (dragged) return;
const handler = this.props.onClick; const handler = this.props.onClick;
if (handler) handler(); if (handler) handler();
this.setState({ navigationHidden: !this.state.navigationHidden });
}; };
handleMouseDown = e => { handleMouseDown = e => {
e.preventDefault(); e.preventDefault();
}; };
initZoomMatrix = () => { _initZoomMatrix = () => {
const { width, height } = this.props; const { width, height } = this.props;
const { clientWidth, clientHeight } = this.container; const { clientWidth, clientHeight } = this.container;
const { offsetWidth, offsetHeight } = this.image; const { offsetWidth, offsetHeight } = this.image;
@ -357,10 +321,7 @@ class ZoomableImage extends PureComponent {
}); });
}; };
handleZoomClick = e => { _toggleZoom () {
e.preventDefault();
e.stopPropagation();
const { scale, zoomMatrix } = this.state; const { scale, zoomMatrix } = this.state;
if ( scale >= zoomMatrix.rate ) { if ( scale >= zoomMatrix.rate ) {
@ -394,10 +355,7 @@ class ZoomableImage extends PureComponent {
this.container.scrollTop = zoomMatrix.scrollTop; this.container.scrollTop = zoomMatrix.scrollTop;
}); });
} }
}
this.container.style.cursor = 'grab';
this.container.style.removeProperty('user-select');
};
setContainerRef = c => { setContainerRef = c => {
this.container = c; this.container = c;
@ -408,29 +366,16 @@ class ZoomableImage extends PureComponent {
}; };
render () { render () {
const { alt, lang, src, width, height, intl } = this.props; const { alt, lang, src, width, height } = this.props;
const { scale, lockTranslate } = this.state; const { scale, lockTranslate, dragged } = this.state;
const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll'; const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
const zoomButtonShouldHide = this.state.navigationHidden || this.props.zoomButtonHidden || this.state.zoomMatrix.rate <= MIN_SCALE ? 'media-modal__zoom-button--hidden' : ''; const cursor = scale === MIN_SCALE ? null : (dragged ? 'grabbing' : 'grab');
const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand);
return ( return (
<>
<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}
style={{
fontSize: '30px', /* Fontawesome's fa-compress fa-expand is larger than fa-close */
}}
/>
<div <div
className='zoomable-image' className='zoomable-image'
ref={this.setContainerRef} ref={this.setContainerRef}
style={{ overflow }} style={{ overflow, cursor, userSelect: 'none' }}
> >
<img <img
role='presentation' role='presentation'
@ -450,10 +395,8 @@ class ZoomableImage extends PureComponent {
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
/> />
</div> </div>
</>
); );
} }
} }
export default injectIntl(ZoomableImage); export default ZoomableImage;

View file

@ -196,7 +196,7 @@ class SwitchingColumnsArea extends PureComponent {
{redirect} {redirect}
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null} {singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={pathName.slice(5)} /> : null} {singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : null}
{/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */} {/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */}
{!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null} {!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null}
{!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null} {!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null}

View 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;
}

View file

@ -0,0 +1,2 @@
// Temporary until we type it correctly
export type MediaAttachment = Immutable.Map<string, unknown>;

View file

@ -10,3 +10,5 @@ export type Status = Immutable.Map<string, unknown>;
type CardShape = Required<ApiPreviewCardJSON>; type CardShape = Required<ApiPreviewCardJSON>;
export type Card = RecordOf<CardShape>; export type Card = RecordOf<CardShape>;
export type MediaAttachment = Immutable.Map<string, unknown>;

View file

@ -2,7 +2,7 @@ import { createReducer, isAnyOf } from '@reduxjs/toolkit';
import { import {
fetchNotificationPolicy, fetchNotificationPolicy,
decreasePendingNotificationsCount, decreasePendingRequestsCount,
updateNotificationsPolicy, updateNotificationsPolicy,
} from 'flavours/glitch/actions/notification_policies'; } from 'flavours/glitch/actions/notification_policies';
import type { NotificationPolicy } from 'flavours/glitch/models/notification_policy'; import type { NotificationPolicy } from 'flavours/glitch/models/notification_policy';
@ -10,10 +10,9 @@ import type { NotificationPolicy } from 'flavours/glitch/models/notification_pol
export const notificationPolicyReducer = export const notificationPolicyReducer =
createReducer<NotificationPolicy | null>(null, (builder) => { createReducer<NotificationPolicy | null>(null, (builder) => {
builder builder
.addCase(decreasePendingNotificationsCount, (state, action) => { .addCase(decreasePendingRequestsCount, (state, action) => {
if (state) { if (state) {
state.summary.pending_notifications_count -= action.payload; state.summary.pending_requests_count -= action.payload;
state.summary.pending_requests_count -= 1;
} }
}) })
.addMatcher( .addMatcher(

View file

@ -38,13 +38,20 @@ const scroll = (
const isScrollBehaviorSupported = const isScrollBehaviorSupported =
'scrollBehavior' in document.documentElement.style; 'scrollBehavior' in document.documentElement.style;
export const scrollRight = (node: Element, position: number) => { export const scrollRight = (node: Element, position: number) =>
if (isScrollBehaviorSupported) requestIdleCallback(() => {
if (isScrollBehaviorSupported) {
node.scrollTo({ left: position, behavior: 'smooth' }); node.scrollTo({ left: position, behavior: 'smooth' });
else scroll(node, 'scrollLeft', position); } else {
}; scroll(node, 'scrollLeft', position);
}
});
export const scrollTop = (node: Element) => { export const scrollTop = (node: Element) =>
if (isScrollBehaviorSupported) node.scrollTo({ top: 0, behavior: 'smooth' }); requestIdleCallback(() => {
else scroll(node, 'scrollTop', 0); if (isScrollBehaviorSupported) {
}; node.scrollTo({ top: 0, behavior: 'smooth' });
} else {
scroll(node, 'scrollTop', 0);
}
});

View file

@ -81,6 +81,18 @@
outline: $ui-button-icon-focus-outline; outline: $ui-button-icon-focus-outline;
} }
&--dangerous {
background-color: var(--error-background-color);
color: var(--on-error-color);
&:active,
&:focus,
&:hover {
background-color: var(--error-active-background-color);
transition: none;
}
}
&--destructive { &--destructive {
&:active, &:active,
&:focus, &:focus,
@ -93,7 +105,7 @@
&:disabled, &:disabled,
&.disabled { &.disabled {
background-color: $ui-primary-color; background-color: $ui-primary-color;
cursor: default; cursor: not-allowed;
} }
&.copyable { &.copyable {
@ -230,6 +242,7 @@
flex: 0 0 auto; flex: 0 0 auto;
a { a {
display: flex;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
@ -299,6 +312,10 @@
} }
} }
&--with-counter {
padding-inline-end: 4px;
}
&__counter { &__counter {
display: block; display: block;
width: auto; width: auto;
@ -620,7 +637,7 @@ body > [data-popper-placement] {
.spoiler-input__input { .spoiler-input__input {
padding: 12px 12px - 5px; padding: 12px 12px - 5px;
background: mix($ui-base-color, $ui-highlight-color, 85%); background: rgba($ui-highlight-color, 0.05);
color: $highlight-text-color; color: $highlight-text-color;
} }
@ -636,19 +653,39 @@ body > [data-popper-placement] {
} }
&__uploads { &__uploads {
display: flex;
gap: 8px;
padding: 0 12px; padding: 0 12px;
flex-wrap: wrap; aspect-ratio: 3/2;
align-self: stretch; }
align-items: flex-start;
align-content: flex-start; .media-gallery {
justify-content: center; gap: 8px;
} }
&__upload { &__upload {
flex: 1 1 0; position: relative;
min-width: calc(50% - 8px); cursor: grab;
&.dragging {
opacity: 0;
}
&.overlay {
height: 100%;
border-radius: 8px;
pointer-events: none;
}
&__drag-handle {
position: absolute;
top: 50%;
inset-inline-start: 0;
transform: translateY(-50%);
color: $white;
background: transparent;
border: 0;
padding: 8px 3px;
cursor: grab;
}
&__actions { &__actions {
display: flex; display: flex;
@ -669,8 +706,7 @@ body > [data-popper-placement] {
&__thumbnail { &__thumbnail {
width: 100%; width: 100%;
height: 144px; height: 100%;
border-radius: 6px;
background-position: center; background-position: center;
background-size: cover; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
@ -1365,7 +1401,7 @@ body > [data-popper-placement] {
.status__content__spoiler-link { .status__content__spoiler-link {
display: inline-flex; // glitch: media icon in spoiler button display: inline-flex; // glitch: media icon in spoiler button
border-radius: 2px; border-radius: 2px;
background: transparent; background: $action-button-color; // glitch: design used in more places
border: 0; border: 0;
color: $inverted-text-color; color: $inverted-text-color;
font-weight: 700; font-weight: 700;
@ -1378,7 +1414,8 @@ body > [data-popper-placement] {
align-items: center; // glitch: content indicator align-items: center; // glitch: content indicator
&:hover { &:hover {
background: lighten($ui-base-color, 33%); // glitch: design used in more places
background: lighten($action-button-color, 7%);
text-decoration: none; text-decoration: none;
} }
@ -1447,6 +1484,14 @@ body > [data-popper-placement] {
} }
} }
.content-warning {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
.media-gallery, .media-gallery,
.video-player, .video-player,
.audio-player, .audio-player,
@ -1508,6 +1553,16 @@ body > [data-popper-placement] {
} }
} }
&__action-bar__button-wrapper {
flex-basis: 0;
// glitch-soc: disable this for now; flex-grow: 1;
&:last-child {
flex-grow: 0;
}
}
&--first-in-thread { &--first-in-thread {
border-top: 1px solid var(--background-border-color); border-top: 1px solid var(--background-border-color);
} }
@ -1776,6 +1831,14 @@ body > [data-popper-placement] {
.media-gallery__item-thumbnail { .media-gallery__item-thumbnail {
cursor: default; cursor: default;
} }
.content-warning {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
} }
.status__prepend { .status__prepend {
@ -2209,13 +2272,14 @@ body > [data-popper-placement] {
display: block; display: block;
position: relative; position: relative;
border-radius: var(--avatar-border-radius); border-radius: var(--avatar-border-radius);
background-color: var(--surface-background-color);
img { img {
display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
border-radius: var(--avatar-border-radius); border-radius: var(--avatar-border-radius);
display: inline-block; // to not show broken images
} }
&-inline { &-inline {
@ -2884,7 +2948,7 @@ a.account__display-name {
&__main { &__main {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
flex: 0 0 auto; flex: 0 1 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -3653,12 +3717,14 @@ $ui-header-logo-wordmark-width: 99px;
margin-top: 10px; margin-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
height: calc(100% - 20px); height: calc(100% - 20px);
overflow-y: auto; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
& > a { &__menu {
flex: 0 0 auto; flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
} }
.logo { .logo {
@ -3669,6 +3735,36 @@ $ui-header-logo-wordmark-width: 99px;
&__logo { &__logo {
margin-bottom: 12px; margin-bottom: 12px;
} }
@media screen and (height <= 710px) {
&__portal {
display: none;
}
}
@media screen and (height <= 765px) {
&__portal .trends__item:nth-child(n + 3) {
display: none;
}
}
@media screen and (height <= 820px) {
&__portal .trends__item:nth-child(n + 4) {
display: none;
}
}
@media screen and (height <= 920px) {
.column-link.column-link--optional {
display: none;
}
}
@media screen and (height <= 1040px) {
.list-panel {
display: none;
}
}
} }
.navigation-panel, .navigation-panel,
@ -4032,22 +4128,6 @@ $ui-header-logo-wordmark-width: 99px;
} }
} }
@media screen and (height <= 810px) {
.trends__item:nth-of-type(3) {
display: none;
}
}
@media screen and (height <= 720px) {
.trends__item:nth-of-type(2) {
display: none;
}
}
@media screen and (height <= 670px) {
display: none;
}
.trends__item { .trends__item {
border-bottom: 0; border-bottom: 0;
padding: 10px; padding: 10px;
@ -6228,9 +6308,24 @@ a.status-card {
height: 100%; height: 100%;
position: relative; position: relative;
&__close, &__buttons {
&__zoom-button { position: absolute;
inset-inline-end: 8px;
top: 8px;
z-index: 100;
display: flex;
gap: 8px;
align-items: center;
.icon-button {
color: rgba($white, 0.7); color: rgba($white, 0.7);
padding: 8px;
.icon {
width: 24px;
height: 24px;
filter: var(--overlay-icon-shadow);
}
&:hover, &:hover,
&:focus, &:focus,
@ -6244,6 +6339,7 @@ a.status-card {
} }
} }
} }
}
.media-modal__closer { .media-modal__closer {
position: absolute; position: absolute;
@ -6323,6 +6419,10 @@ a.status-card {
.icon-button { .icon-button {
color: $white; color: $white;
.icon {
filter: var(--overlay-icon-shadow);
}
&:hover, &:hover,
&:focus, &:focus,
&:active { &:active {
@ -6381,6 +6481,7 @@ a.status-card {
.media-modal__page-dot { .media-modal__page-dot {
flex: 0 0 auto; flex: 0 0 auto;
background-color: $white; background-color: $white;
filter: var(--overlay-icon-shadow);
opacity: 0.4; opacity: 0.4;
height: 6px; height: 6px;
width: 6px; width: 6px;
@ -6401,28 +6502,6 @@ a.status-card {
} }
} }
.media-modal__close {
position: absolute;
inset-inline-end: 8px;
top: 8px;
z-index: 100;
}
.media-modal__zoom-button {
position: absolute;
inset-inline-end: 64px;
top: 8px;
z-index: 100;
pointer-events: auto;
transition: opacity 0.3s linear;
will-change: opacity;
}
.media-modal__zoom-button--hidden {
pointer-events: none;
opacity: 0;
}
.onboarding-modal, .onboarding-modal,
.error-modal, .error-modal,
.embed-modal { .embed-modal {
@ -6679,6 +6758,14 @@ a.status-card {
display: flex; display: flex;
gap: 16px; gap: 16px;
align-items: center; align-items: center;
strong {
font-weight: 700;
}
}
&--deemphasized {
color: $secondary-text-color;
} }
&__icon { &__icon {
@ -7398,7 +7485,7 @@ img.modal-warning {
.media-gallery__actions { .media-gallery__actions {
position: absolute; position: absolute;
bottom: 6px; top: 6px;
inset-inline-end: 6px; inset-inline-end: 6px;
display: flex; display: flex;
gap: 2px; gap: 2px;
@ -7421,75 +7508,16 @@ img.modal-warning {
.media-gallery__item__badges { .media-gallery__item__badges {
position: absolute; position: absolute;
bottom: 8px; bottom: 8px;
inset-inline-start: 8px; inset-inline-end: 8px;
display: flex; display: flex;
gap: 2px; gap: 2px;
&--layout-2 {
.media-gallery__item:nth-child(1) {
border-end-end-radius: 0;
border-start-end-radius: 0;
} }
.media-gallery__item:nth-child(2) { .media-gallery__alt__label {
border-start-start-radius: 0; display: block;
border-end-start-radius: 0; text-align: center;
}
}
&--layout-3 {
.media-gallery__item:nth-child(1) {
border-end-end-radius: 0;
border-start-end-radius: 0;
}
.media-gallery__item:nth-child(2) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
.media-gallery__item:nth-child(3) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-start-end-radius: 0;
}
}
&--layout-4 {
.media-gallery__item:nth-child(1) {
border-end-end-radius: 0;
border-start-end-radius: 0;
border-end-start-radius: 0;
}
.media-gallery__item:nth-child(2) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
.media-gallery__item:nth-child(3) {
border-start-start-radius: 0;
border-start-end-radius: 0;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
.media-gallery__item:nth-child(4) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-start-end-radius: 0;
}
}
}
.media-gallery__alt__label,
.media-gallery__gifv__label {
display: flex;
align-items: center;
justify-content: center;
color: $white; color: $white;
border: 0;
background: rgba($black, 0.65); background: rgba($black, 0.65);
backdrop-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%); backdrop-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%);
padding: 3px 8px; padding: 3px 8px;
@ -7497,8 +7525,41 @@ img.modal-warning {
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
z-index: 1; z-index: 1;
pointer-events: none;
line-height: 20px; line-height: 20px;
cursor: pointer;
pointer-events: auto;
&--non-interactive {
pointer-events: none;
}
}
.media-gallery__alt__popover {
background: rgba($black, 0.65);
backdrop-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%);
border-radius: 4px;
box-shadow: var(--dropdown-shadow);
padding: 16px;
min-width: 16em;
min-height: 2em;
max-width: 22em;
max-height: 30em;
overflow-y: auto;
h4 {
font-size: 15px;
line-height: 20px;
font-weight: 500;
color: $white;
margin-bottom: 8px;
}
p {
font-size: 15px;
line-height: 20px;
color: rgba($white, 0.85);
white-space: pre-line;
}
} }
.attachment-list { .attachment-list {
@ -7572,10 +7633,68 @@ img.modal-warning {
width: 100%; width: 100%;
min-height: 64px; min-height: 64px;
display: grid; display: grid;
grid-template-columns: 50% 50%; grid-template-columns: 1fr 1fr;
grid-template-rows: 50% 50%; grid-template-rows: 1fr 1fr;
gap: 2px; gap: 2px;
&--layout-2 {
& > .media-gallery__item:nth-child(1) {
border-end-end-radius: 0;
border-start-end-radius: 0;
}
& > .media-gallery__item:nth-child(2) {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
}
&--layout-3 {
& > .media-gallery__item:nth-child(1) {
border-end-end-radius: 0;
border-start-end-radius: 0;
}
& > .media-gallery__item:nth-child(2) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
& > .media-gallery__item:nth-child(3) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-start-end-radius: 0;
}
}
&--layout-4 {
& > .media-gallery__item:nth-child(1) {
border-end-end-radius: 0;
border-start-end-radius: 0;
border-end-start-radius: 0;
}
& > .media-gallery__item:nth-child(2) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
& > .media-gallery__item:nth-child(3) {
border-start-start-radius: 0;
border-start-end-radius: 0;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
& > .media-gallery__item:nth-child(4) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-start-end-radius: 0;
}
}
@include fullwidth-gallery; @include fullwidth-gallery;
} }
@ -7586,6 +7705,9 @@ img.modal-warning {
position: relative; position: relative;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
outline: 1px solid var(--media-outline-color);
outline-offset: -1px;
z-index: 1;
&--tall { &--tall {
grid-row: span 2; grid-row: span 2;
@ -7602,15 +7724,44 @@ img.modal-warning {
&.letterbox { &.letterbox {
background: $base-shadow-color; background: $base-shadow-color;
} }
&--square {
aspect-ratio: 1;
}
&__overlay {
position: absolute;
top: 0;
inset-inline-start: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
height: 100%;
pointer-events: none;
padding: 8px;
z-index: 1;
&--corner {
align-items: flex-start;
justify-content: flex-end;
}
.icon {
color: $white;
filter: var(--overlay-icon-shadow);
}
}
} }
.media-gallery__item-thumbnail { .media-gallery__item-thumbnail {
cursor: zoom-in; cursor: pointer;
display: block; display: block;
text-decoration: none; text-decoration: none;
color: $secondary-text-color; color: $secondary-text-color;
position: relative; position: relative;
z-index: 1; z-index: -1;
&, &,
img { img {
@ -7632,7 +7783,7 @@ img.modal-warning {
position: absolute; position: absolute;
top: 0; top: 0;
inset-inline-start: 0; inset-inline-start: 0;
z-index: 0; z-index: -2;
background: $base-overlay-background; background: $base-overlay-background;
&--hidden { &--hidden {
@ -7645,10 +7796,11 @@ img.modal-warning {
overflow: hidden; overflow: hidden;
position: relative; position: relative;
width: 100%; width: 100%;
z-index: -1;
} }
.media-gallery__item-gifv-thumbnail { .media-gallery__item-gifv-thumbnail {
cursor: zoom-in; cursor: pointer;
height: 100%; height: 100%;
width: 100%; width: 100%;
object-fit: contain; object-fit: contain;
@ -7660,13 +7812,6 @@ img.modal-warning {
} }
} }
.media-gallery__item-thumbnail-label {
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
clip: rect(1px, 1px, 1px, 1px);
overflow: hidden;
position: absolute;
}
/* End Media Gallery */ /* End Media Gallery */
.detailed, .detailed,
@ -7689,6 +7834,8 @@ img.modal-warning {
border-radius: 8px; border-radius: 8px;
padding-bottom: 44px; padding-bottom: 44px;
width: 100%; width: 100%;
outline: 1px solid var(--media-outline-color);
outline-offset: -1px;
&.editable { &.editable {
border-radius: 0; border-radius: 0;
@ -7745,6 +7892,7 @@ img.modal-warning {
.video-player__controls { .video-player__controls {
padding-top: 10px; padding-top: 10px;
background: transparent; background: transparent;
z-index: 1;
} }
} }
@ -7758,16 +7906,15 @@ img.modal-warning {
color: $white; color: $white;
display: flex; display: flex;
align-items: center; align-items: center;
outline: 1px solid var(--media-outline-color);
outline-offset: -1px;
z-index: 2;
&.editable { &.editable {
border-radius: 0; border-radius: 0;
height: 100% !important; height: 100% !important;
} }
&:focus {
outline: 0;
}
.detailed-status & { .detailed-status & {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -7779,7 +7926,7 @@ img.modal-warning {
display: block; display: block;
max-width: 100vw; max-width: 100vw;
max-height: 80vh; max-height: 80vh;
z-index: 1; z-index: -2;
position: relative; position: relative;
} }
@ -7806,7 +7953,7 @@ img.modal-warning {
&__controls { &__controls {
position: absolute; position: absolute;
direction: ltr; direction: ltr;
z-index: 2; z-index: -1;
bottom: 0; bottom: 0;
inset-inline-start: 0; inset-inline-start: 0;
inset-inline-end: 0; inset-inline-end: 0;
@ -8121,26 +8268,16 @@ img.modal-warning {
} }
.account-gallery__container { .account-gallery__container {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: 1fr 1fr 1fr;
padding: 4px 2px; gap: 2px;
.media-gallery__item {
border-radius: 0;
} }
.account-gallery__item { .load-more {
border: 0; grid-column: span 3;
box-sizing: border-box;
display: block;
position: relative;
border-radius: 4px;
overflow: hidden;
margin: 2px;
&__icons {
position: absolute;
top: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
font-size: 24px;
} }
} }
@ -11214,39 +11351,59 @@ noscript {
} }
&__embedded-status { &__embedded-status {
display: flex;
flex-direction: column;
gap: 8px;
cursor: pointer; cursor: pointer;
&__account { &__account {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
margin-bottom: 8px;
color: $dark-text-color; color: $dark-text-color;
font-size: 15px;
line-height: 22px;
bdi { bdi {
color: inherit; color: $darker-text-color;
} }
} }
.account__avatar { /* glitch: used for CWs */
opacity: 0.5; p {
font-size: 15px;
color: $darker-text-color;
} }
&__content { &__content {
display: -webkit-box; display: -webkit-box;
font-size: 15px; font-size: 15px;
line-height: 22px; line-height: 22px;
color: $dark-text-color; color: $darker-text-color;
-webkit-line-clamp: 4; -webkit-line-clamp: 4;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
max-height: 4 * 22px; max-height: 4 * 22px;
overflow: hidden; overflow: hidden;
p {
display: none;
&:first-child {
display: initial;
}
}
p, p,
a { a {
color: inherit; color: inherit;
} }
} }
.reply-indicator__attachments {
font-size: 15px;
line-height: 22px;
color: $dark-text-color;
}
} }
} }
@ -11522,3 +11679,54 @@ noscript {
} }
} }
} }
.content-warning {
box-sizing: border-box;
background: rgba($ui-highlight-color, 0.05);
color: $secondary-text-color;
border-top: 1px solid;
border-bottom: 1px solid;
border-color: rgba($ui-highlight-color, 0.15);
padding: 8px (5px + 8px);
position: relative;
font-size: 15px;
line-height: 22px;
p {
margin-bottom: 8px;
}
.link-button {
font-size: inherit;
line-height: inherit;
font-weight: 500;
}
&::before,
&::after {
content: '';
display: block;
position: absolute;
height: 100%;
background: url('~images/warning-stripes.svg') repeat-y;
width: 5px;
top: 0;
}
&::before {
border-start-start-radius: 4px;
border-end-start-radius: 4px;
inset-inline-start: 0;
}
&::after {
border-start-end-radius: 4px;
border-end-end-radius: 4px;
inset-inline-end: 0;
}
&--filter::before,
&--filter::after {
background-image: url('~images/filter-stripes.svg');
}
}

View file

@ -86,9 +86,7 @@
color: $primary-text-color; color: $primary-text-color;
transition: all 100ms ease-in; transition: all 100ms ease-in;
font-size: 14px; font-size: 14px;
padding: 0 16px; padding: 8px 16px;
line-height: 36px;
height: 36px;
text-decoration: none; text-decoration: none;
margin-bottom: 4px; margin-bottom: 4px;

View file

@ -313,6 +313,10 @@ code {
ul { ul {
columns: 2; columns: 2;
@media screen and (max-width: $mobile-breakpoint) {
columns: 1;
}
} }
} }

View file

@ -1,3 +1,5 @@
@use 'sass:color';
// Dependent colors // Dependent colors
$black: #000000; $black: #000000;
$white: #ffffff; $white: #ffffff;
@ -47,11 +49,19 @@ $account-background-color: $white !default;
// Invert darkened and lightened colors // Invert darkened and lightened colors
@function darken($color, $amount) { @function darken($color, $amount) {
@return hsl(hue($color), saturation($color), lightness($color) + $amount); @return hsl(
hue($color),
color.channel($color, 'saturation', $space: hsl),
color.channel($color, 'lightness', $space: hsl) + $amount
);
} }
@function lighten($color, $amount) { @function lighten($color, $amount) {
@return hsl(hue($color), saturation($color), lightness($color) - $amount); @return hsl(
hue($color),
color.channel($color, 'saturation', $space: hsl),
color.channel($color, 'lightness', $space: hsl) - $amount
);
} }
$emojis-requiring-inversion: 'chains'; $emojis-requiring-inversion: 'chains';

View file

@ -56,7 +56,6 @@ table {
@supports not selector(::-webkit-scrollbar) { @supports not selector(::-webkit-scrollbar) {
html { html {
scrollbar-color: $action-button-color var(--background-border-color); scrollbar-color: $action-button-color var(--background-border-color);
scrollbar-width: thin;
} }
} }

View file

@ -355,6 +355,10 @@ a.table-action-link {
@media screen and (max-width: $no-gap-breakpoint) { @media screen and (max-width: $no-gap-breakpoint) {
border-top: 1px solid var(--background-border-color); border-top: 1px solid var(--background-border-color);
} }
&--no-toolbar {
border-top: 1px solid var(--background-border-color);
}
} }
@media screen and (width <= 870px) { @media screen and (width <= 870px) {

View file

@ -87,7 +87,7 @@ $media-modal-media-max-width: 100%;
// put margins on top and bottom of image to avoid the screen covered by image. // put margins on top and bottom of image to avoid the screen covered by image.
$media-modal-media-max-height: 80%; $media-modal-media-max-height: 80%;
$no-gap-breakpoint: 1207px; $no-gap-breakpoint: 1175px;
$mobile-breakpoint: 630px; $mobile-breakpoint: 630px;
$font-sans-serif: 'mastodon-font-sans-serif' !default; $font-sans-serif: 'mastodon-font-sans-serif' !default;
@ -117,4 +117,9 @@ $dismiss-overlay-width: 4rem;
--surface-variant-active-background-color: #{lighten($ui-base-color, 4%)}; --surface-variant-active-background-color: #{lighten($ui-base-color, 4%)};
--on-surface-color: #{transparentize($ui-base-color, 0.5)}; --on-surface-color: #{transparentize($ui-base-color, 0.5)};
--avatar-border-radius: 8px; --avatar-border-radius: 8px;
--media-outline-color: #{rgba(#fcf8ff, 0.15)};
--overlay-icon-shadow: drop-shadow(0 0 8px #{rgba($base-shadow-color, 0.25)});
--error-background-color: #{darken($error-red, 16%)};
--error-active-background-color: #{darken($error-red, 12%)};
--on-error-color: #fff;
} }

View file

@ -0,0 +1,23 @@
import { debounce } from 'lodash';
import type { AppDispatch } from 'flavours/glitch/store';
export const debounceWithDispatchAndArguments = <T>(
fn: (dispatch: AppDispatch, ...args: T[]) => void,
{ delay = 100 },
) => {
let argumentBuffer: T[] = [];
let dispatchBuffer: AppDispatch;
const wrapped = debounce(() => {
const tmpBuffer = argumentBuffer;
argumentBuffer = [];
fn(dispatchBuffer, ...tmpBuffer);
}, delay);
return (dispatch: AppDispatch, ...args: T[]) => {
dispatchBuffer = dispatch;
argumentBuffer.push(...args);
wrapped();
};
};

View 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;
}

View file

@ -1,4 +1,5 @@
import { browserHistory } from 'mastodon/components/router'; import { browserHistory } from 'mastodon/components/router';
import { debounceWithDispatchAndArguments } from 'mastodon/utils/debounce';
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
@ -449,6 +450,20 @@ export function expandFollowingFail(id, error) {
}; };
} }
const debouncedFetchRelationships = debounceWithDispatchAndArguments((dispatch, ...newAccountIds) => {
if (newAccountIds.length === 0) {
return;
}
dispatch(fetchRelationshipsRequest(newAccountIds));
api().get(`/api/v1/accounts/relationships?with_suspended=true&${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
dispatch(fetchRelationshipsSuccess({ relationships: response.data }));
}).catch(error => {
dispatch(fetchRelationshipsFail(error));
});
}, { delay: 500 });
export function fetchRelationships(accountIds) { export function fetchRelationships(accountIds) {
return (dispatch, getState) => { return (dispatch, getState) => {
const state = getState(); const state = getState();
@ -460,13 +475,7 @@ export function fetchRelationships(accountIds) {
return; return;
} }
dispatch(fetchRelationshipsRequest(newAccountIds)); debouncedFetchRelationships(dispatch, ...newAccountIds);
api().get(`/api/v1/accounts/relationships?with_suspended=true&${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
dispatch(fetchRelationshipsSuccess({ relationships: response.data }));
}).catch(error => {
dispatch(fetchRelationshipsFail(error));
});
}; };
} }

View file

@ -1,5 +1,7 @@
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import { AxiosError } from 'axios';
const messages = defineMessages({ const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
@ -50,6 +52,11 @@ export const showAlertForError = (error, skipNotFound = false) => {
}); });
} }
// An aborted request, e.g. due to reloading the browser window, it not really error
if (error.code === AxiosError.ECONNABORTED) {
return { type: ALERT_NOOP };
}
console.error(error); console.error(error);
return showAlert({ return showAlert({

View file

@ -68,10 +68,15 @@ function dispatchAssociatedRecords(
dispatch(importFetchedStatuses(fetchedStatuses)); dispatch(importFetchedStatuses(fetchedStatuses));
} }
const supportedGroupedNotificationTypes = ['favourite', 'reblog'];
export const fetchNotifications = createDataLoadingThunk( export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch', 'notificationGroups/fetch',
async (_params, { getState }) => async (_params, { getState }) =>
apiFetchNotificationGroups({ exclude_types: getExcludedTypes(getState()) }), apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
exclude_types: getExcludedTypes(getState()),
}),
({ notifications, accounts, statuses }, { dispatch }) => { ({ notifications, accounts, statuses }, { dispatch }) => {
dispatch(importFetchedAccounts(accounts)); dispatch(importFetchedAccounts(accounts));
dispatch(importFetchedStatuses(statuses)); dispatch(importFetchedStatuses(statuses));
@ -93,6 +98,7 @@ export const fetchNotificationsGap = createDataLoadingThunk(
'notificationGroups/fetchGap', 'notificationGroups/fetchGap',
async (params: { gap: NotificationGap }, { getState }) => async (params: { gap: NotificationGap }, { getState }) =>
apiFetchNotificationGroups({ apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
max_id: params.gap.maxId, max_id: params.gap.maxId,
exclude_types: getExcludedTypes(getState()), exclude_types: getExcludedTypes(getState()),
}), }),
@ -109,6 +115,7 @@ export const pollRecentNotifications = createDataLoadingThunk(
'notificationGroups/pollRecentNotifications', 'notificationGroups/pollRecentNotifications',
async (_params, { getState }) => { async (_params, { getState }) => {
return apiFetchNotificationGroups({ return apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
max_id: undefined, max_id: undefined,
exclude_types: getExcludedTypes(getState()), exclude_types: getExcludedTypes(getState()),
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones // In slow mode, we don't want to include notifications that duplicate the already-displayed ones

View file

@ -17,6 +17,6 @@ export const updateNotificationsPolicy = createDataLoadingThunk(
(policy: Partial<NotificationPolicy>) => apiUpdateNotificationsPolicy(policy), (policy: Partial<NotificationPolicy>) => apiUpdateNotificationsPolicy(policy),
); );
export const decreasePendingNotificationsCount = createAction<number>( export const decreasePendingRequestsCount = createAction<number>(
'notificationPolicy/decreasePendingNotificationCount', 'notificationPolicy/decreasePendingRequestsCount',
); );

View file

@ -13,11 +13,11 @@ import type {
ApiNotificationJSON, ApiNotificationJSON,
} from 'mastodon/api_types/notifications'; } from 'mastodon/api_types/notifications';
import type { ApiStatusJSON } from 'mastodon/api_types/statuses'; import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
import type { AppDispatch, RootState } from 'mastodon/store'; import type { AppDispatch } from 'mastodon/store';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import { importFetchedAccounts, importFetchedStatuses } from './importer'; import { importFetchedAccounts, importFetchedStatuses } from './importer';
import { decreasePendingNotificationsCount } from './notification_policies'; import { decreasePendingRequestsCount } from './notification_policies';
// TODO: refactor with notification_groups // TODO: refactor with notification_groups
function dispatchAssociatedRecords( function dispatchAssociatedRecords(
@ -169,19 +169,11 @@ export const expandNotificationsForRequest = createDataLoadingThunk(
}, },
); );
const selectNotificationCountForRequest = (state: RootState, id: string) => {
const requests = state.notificationRequests.items;
const thisRequest = requests.find((request) => request.id === id);
return thisRequest ? thisRequest.notifications_count : 0;
};
export const acceptNotificationRequest = createDataLoadingThunk( export const acceptNotificationRequest = createDataLoadingThunk(
'notificationRequest/accept', 'notificationRequest/accept',
({ id }: { id: string }) => apiAcceptNotificationRequest(id), ({ id }: { id: string }) => apiAcceptNotificationRequest(id),
(_data, { dispatch, getState, discardLoadData, actionArg: { id } }) => { (_data, { dispatch, discardLoadData }) => {
const count = selectNotificationCountForRequest(getState(), id); dispatch(decreasePendingRequestsCount(1));
dispatch(decreasePendingNotificationsCount(count));
// The payload is not used in any functions // The payload is not used in any functions
return discardLoadData; return discardLoadData;
@ -191,10 +183,8 @@ export const acceptNotificationRequest = createDataLoadingThunk(
export const dismissNotificationRequest = createDataLoadingThunk( export const dismissNotificationRequest = createDataLoadingThunk(
'notificationRequest/dismiss', 'notificationRequest/dismiss',
({ id }: { id: string }) => apiDismissNotificationRequest(id), ({ id }: { id: string }) => apiDismissNotificationRequest(id),
(_data, { dispatch, getState, discardLoadData, actionArg: { id } }) => { (_data, { dispatch, discardLoadData }) => {
const count = selectNotificationCountForRequest(getState(), id); dispatch(decreasePendingRequestsCount(1));
dispatch(decreasePendingNotificationsCount(count));
// The payload is not used in any functions // The payload is not used in any functions
return discardLoadData; return discardLoadData;
@ -204,13 +194,8 @@ export const dismissNotificationRequest = createDataLoadingThunk(
export const acceptNotificationRequests = createDataLoadingThunk( export const acceptNotificationRequests = createDataLoadingThunk(
'notificationRequests/acceptBulk', 'notificationRequests/acceptBulk',
({ ids }: { ids: string[] }) => apiAcceptNotificationRequests(ids), ({ ids }: { ids: string[] }) => apiAcceptNotificationRequests(ids),
(_data, { dispatch, getState, discardLoadData, actionArg: { ids } }) => { (_data, { dispatch, discardLoadData, actionArg: { ids } }) => {
const count = ids.reduce( dispatch(decreasePendingRequestsCount(ids.length));
(count, id) => count + selectNotificationCountForRequest(getState(), id),
0,
);
dispatch(decreasePendingNotificationsCount(count));
// The payload is not used in any functions // The payload is not used in any functions
return discardLoadData; return discardLoadData;
@ -220,13 +205,8 @@ export const acceptNotificationRequests = createDataLoadingThunk(
export const dismissNotificationRequests = createDataLoadingThunk( export const dismissNotificationRequests = createDataLoadingThunk(
'notificationRequests/dismissBulk', 'notificationRequests/dismissBulk',
({ ids }: { ids: string[] }) => apiDismissNotificationRequests(ids), ({ ids }: { ids: string[] }) => apiDismissNotificationRequests(ids),
(_data, { dispatch, getState, discardLoadData, actionArg: { ids } }) => { (_data, { dispatch, discardLoadData, actionArg: { ids } }) => {
const count = ids.reduce( dispatch(decreasePendingRequestsCount(ids.length));
(count, id) => count + selectNotificationCountForRequest(getState(), id),
0,
);
dispatch(decreasePendingNotificationsCount(count));
// The payload is not used in any functions // The payload is not used in any functions
return discardLoadData; return discardLoadData;

View file

@ -10,7 +10,7 @@ import api, { getLinks } from '../api';
import { unescapeHTML } from '../utils/html'; import { unescapeHTML } from '../utils/html';
import { requestNotificationPermission } from '../utils/notifications'; import { requestNotificationPermission } from '../utils/notifications';
import { fetchFollowRequests, fetchRelationships } from './accounts'; import { fetchFollowRequests } from './accounts';
import { import {
importFetchedAccount, importFetchedAccount,
importFetchedAccounts, importFetchedAccounts,
@ -56,14 +56,6 @@ defineMessages({
group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
}); });
const fetchRelatedRelationships = (dispatch, notifications) => {
const accountIds = notifications.filter(item => ['follow', 'follow_request', 'admin.sign_up'].indexOf(item.type) !== -1).map(item => item.account.id);
if (accountIds.length > 0) {
dispatch(fetchRelationships(accountIds));
}
};
export const loadPending = () => ({ export const loadPending = () => ({
type: NOTIFICATIONS_LOAD_PENDING, type: NOTIFICATIONS_LOAD_PENDING,
}); });
@ -106,8 +98,6 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered})); dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered}));
fetchRelatedRelationships(dispatch, [notification]);
} else if (playSound && !filtered) { } else if (playSound && !filtered) {
dispatch({ dispatch({
type: NOTIFICATIONS_UPDATE_NOOP, type: NOTIFICATIONS_UPDATE_NOOP,
@ -199,7 +189,6 @@ export function expandNotifications({ maxId = undefined, forceLoad = false }) {
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account))); dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems)); dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
fetchRelatedRelationships(dispatch, response.data);
dispatch(submitMarkers()); dispatch(submitMarkers());
} catch(error) { } catch(error) {
dispatch(expandNotificationsFail(error, isLoadingMore)); dispatch(expandNotificationsFail(error, isLoadingMore));

View file

@ -42,6 +42,9 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => {
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default function api(withAuthorization = true) { export default function api(withAuthorization = true) {
return axios.create({ return axios.create({
transitional: {
clarifyTimeoutError: true,
},
headers: { headers: {
...csrfHeader, ...csrfHeader,
...(withAuthorization ? authorizationTokenFromInitialState() : {}), ...(withAuthorization ? authorizationTokenFromInitialState() : {}),
@ -67,6 +70,7 @@ export async function apiRequest<ApiResponse = unknown>(
args: { args: {
params?: RequestParamsOrData; params?: RequestParamsOrData;
data?: RequestParamsOrData; data?: RequestParamsOrData;
timeout?: number;
} = {}, } = {},
) { ) {
const { data } = await api().request<ApiResponse>({ const { data } = await api().request<ApiResponse>({

View file

@ -31,6 +31,7 @@ export const apiFetchNotifications = async (
export const apiFetchNotificationGroups = async (params?: { export const apiFetchNotificationGroups = async (params?: {
url?: string; url?: string;
grouped_types?: string[];
exclude_types?: string[]; exclude_types?: string[];
max_id?: string; max_id?: string;
since_id?: string; since_id?: string;
@ -91,5 +92,5 @@ export const apiAcceptNotificationRequests = async (id: string[]) => {
}; };
export const apiDismissNotificationRequests = async (id: string[]) => { export const apiDismissNotificationRequests = async (id: string[]) => {
return apiRequestPost('v1/notifications/dismiss/dismiss', { id }); return apiRequestPost('v1/notifications/requests/dismiss', { id });
}; };

View 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>
</>
);
};

View file

@ -7,6 +7,7 @@ interface BaseProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> { extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
block?: boolean; block?: boolean;
secondary?: boolean; secondary?: boolean;
dangerous?: boolean;
} }
interface PropsChildren extends PropsWithChildren<BaseProps> { interface PropsChildren extends PropsWithChildren<BaseProps> {
@ -26,6 +27,7 @@ export const Button: React.FC<Props> = ({
disabled, disabled,
block, block,
secondary, secondary,
dangerous,
className, className,
title, title,
text, text,
@ -46,6 +48,7 @@ export const Button: React.FC<Props> = ({
className={classNames('button', className, { className={classNames('button', className, {
'button-secondary': secondary, 'button-secondary': secondary,
'button--block': block, 'button--block': block,
'button--dangerous': dangerous,
})} })}
disabled={disabled} disabled={disabled}
onClick={handleClick} onClick={handleClick}

View file

@ -10,7 +10,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { AltTextBadge } from 'mastodon/components/alt_text_badge';
import { Blurhash } from 'mastodon/components/blurhash'; import { Blurhash } from 'mastodon/components/blurhash';
import { formatTime } from 'mastodon/features/video';
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state'; import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
@ -57,7 +59,7 @@ class Item extends PureComponent {
hoverToPlay () { hoverToPlay () {
const { attachment } = this.props; const { attachment } = this.props;
return !this.getAutoPlay() && attachment.get('type') === 'gifv'; return !this.getAutoPlay() && ['gifv', 'video'].includes(attachment.get('type'));
} }
handleClick = (e) => { handleClick = (e) => {
@ -96,7 +98,7 @@ class Item extends PureComponent {
} }
if (attachment.get('description')?.length > 0) { if (attachment.get('description')?.length > 0) {
badges.push(<span key='alt' className='media-gallery__alt__label'>ALT</span>); badges.push(<AltTextBadge key='alt' description={attachment.get('description')} />);
} }
const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
@ -150,10 +152,15 @@ class Item extends PureComponent {
/> />
</a> </a>
); );
} else if (attachment.get('type') === 'gifv') { } else if (['gifv', 'video'].includes(attachment.get('type'))) {
const autoPlay = this.getAutoPlay(); const autoPlay = this.getAutoPlay();
const duration = attachment.getIn(['meta', 'original', 'duration']);
badges.push(<span key='gif' className='media-gallery__gifv__label'>GIF</span>); if (attachment.get('type') === 'gifv') {
badges.push(<span key='gif' className='media-gallery__alt__label media-gallery__alt__label--non-interactive'>GIF</span>);
} else {
badges.push(<span key='video' className='media-gallery__alt__label media-gallery__alt__label--non-interactive'>{formatTime(Math.floor(duration))}</span>);
}
thumbnail = ( thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
@ -167,6 +174,7 @@ class Item extends PureComponent {
onClick={this.handleClick} onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter} onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave} onMouseLeave={this.handleMouseLeave}
onLoadedData={this.handleImageLoad}
autoPlay={autoPlay} autoPlay={autoPlay}
playsInline playsInline
loop loop

View file

@ -148,7 +148,7 @@ class ModalRoot extends PureComponent {
return ( return (
<div className='modal-root' ref={this.setRef}> <div className='modal-root' ref={this.setRef}>
<div style={{ pointerEvents: visible ? 'auto' : 'none' }}> <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.7)` : null }} /> <div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.9)` : null }} />
<div role='dialog' className='modal-root__container'>{children}</div> <div role='dialog' className='modal-root__container'>{children}</div>
</div> </div>
</div> </div>

View file

@ -4,22 +4,22 @@ import AccountNavigation from 'mastodon/features/account/navigation';
import Trends from 'mastodon/features/getting_started/containers/trends_container'; import Trends from 'mastodon/features/getting_started/containers/trends_container';
import { showTrends } from 'mastodon/initial_state'; import { showTrends } from 'mastodon/initial_state';
const DefaultNavigation: React.FC = () => const DefaultNavigation: React.FC = () => (showTrends ? <Trends /> : null);
showTrends ? (
<>
<div className='flex-spacer' />
<Trends />
</>
) : null;
export const NavigationPortal: React.FC = () => ( export const NavigationPortal: React.FC = () => (
<div className='navigation-panel__portal'>
<Switch> <Switch>
<Route path='/@:acct' exact component={AccountNavigation} /> <Route path='/@:acct' exact component={AccountNavigation} />
<Route path='/@:acct/tagged/:tagged?' exact component={AccountNavigation} /> <Route
path='/@:acct/tagged/:tagged?'
exact
component={AccountNavigation}
/>
<Route path='/@:acct/with_replies' exact component={AccountNavigation} /> <Route path='/@:acct/with_replies' exact component={AccountNavigation} />
<Route path='/@:acct/followers' exact component={AccountNavigation} /> <Route path='/@:acct/followers' exact component={AccountNavigation} />
<Route path='/@:acct/following' exact component={AccountNavigation} /> <Route path='/@:acct/following' exact component={AccountNavigation} />
<Route path='/@:acct/media' exact component={AccountNavigation} /> <Route path='/@:acct/media' exact component={AccountNavigation} />
<Route component={DefaultNavigation} /> <Route component={DefaultNavigation} />
</Switch> </Switch>
</div>
); );

View file

@ -51,7 +51,8 @@ function normalizePath(
if ( if (
layoutFromWindow() === 'multi-column' && layoutFromWindow() === 'multi-column' &&
!location.pathname?.startsWith('/deck') location.pathname &&
!location.pathname.startsWith('/deck')
) { ) {
location.pathname = `/deck${location.pathname}`; location.pathname = `/deck${location.pathname}`;
} }

View file

@ -449,7 +449,25 @@ class Status extends ImmutablePureComponent {
} else if (status.get('media_attachments').size > 0) { } else if (status.get('media_attachments').size > 0) {
const language = status.getIn(['translation', 'language']) || status.get('language'); const language = status.getIn(['translation', 'language']) || status.get('language');
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { if (['image', 'gifv'].includes(status.getIn(['media_attachments', 0, 'type'])) || status.get('media_attachments').size > 1) {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => (
<Component
media={status.get('media_attachments')}
lang={language}
sensitive={status.get('sensitive')}
height={110}
onOpenMedia={this.handleOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]); const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
@ -501,24 +519,6 @@ class Status extends ImmutablePureComponent {
)} )}
</Bundle> </Bundle>
); );
} else {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => (
<Component
media={status.get('media_attachments')}
lang={language}
sensitive={status.get('sensitive')}
height={110}
onOpenMedia={this.handleOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>
);
} }
} else if (status.get('spoiler_text').length === 0 && status.get('card')) { } else if (status.get('spoiler_text').length === 0 && status.get('card')) {
media = ( media = (

View file

@ -375,11 +375,19 @@ class StatusActionBar extends ImmutablePureComponent {
return ( return (
<div className='status__action-bar'> <div className='status__action-bar'>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} /> <IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
</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='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} /> <IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={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} /> <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} /> <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>
<div className='status__action-bar__button-wrapper'>
<DropdownMenuContainer <DropdownMenuContainer
scrollKey={scrollKey} scrollKey={scrollKey}
status={status} status={status}
@ -390,6 +398,7 @@ class StatusActionBar extends ImmutablePureComponent {
title={intl.formatMessage(messages.more)} title={intl.formatMessage(messages.more)}
/> />
</div> </div>
</div>
); );
} }

View file

@ -43,10 +43,7 @@ class AccountNavigation extends PureComponent {
} }
return ( return (
<>
<div className='flex-spacer' />
<FeaturedTags accountId={accountId} tagged={tagged} /> <FeaturedTags accountId={accountId} tagged={tagged} />
</>
); );
} }

View file

@ -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 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/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.getIn(['account', 'acct'])}/${status.get('id')}`} 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>
);
}
}

View file

@ -0,0 +1,200 @@
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 'mastodon/components/alt_text_badge';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import { formatTime } from 'mastodon/features/video';
import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state';
import type { Status, MediaAttachment } from 'mastodon/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 statusId = status.get('id') as string;
const acct = status.getIn(['account', 'acct']) 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={`/@${acct}/${statusId}`}
onClick={handleClick}
target='_blank'
rel='noopener noreferrer'
>
{thumbnail}
</a>
{badges.length > 0 && (
<div className='media-gallery__item__badges'>{badges}</div>
)}
</div>
);
};

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