mirror of
https://git.kescher.at/CatCatNya/catstodon.git
synced 2024-11-25 16:41:36 +01:00
Merge remote-tracking branch 'upstream/main' into develop
Also apply "Make addReaction and removeReaction optional props", therefore: Co-authored by: Essem <smswessem@gmail.com>
This commit is contained in:
commit
655d28f6a7
494 changed files with 7028 additions and 5651 deletions
|
@ -333,7 +333,7 @@ module.exports = defineConfig({
|
||||||
],
|
],
|
||||||
|
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: true,
|
projectService: true,
|
||||||
tsconfigRootDir: __dirname,
|
tsconfigRootDir: __dirname,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
3
.github/codecov.yml
vendored
3
.github/codecov.yml
vendored
|
@ -1,4 +1,3 @@
|
||||||
annotations: false
|
|
||||||
comment: false # Do not leave PR comments
|
comment: false # Do not leave PR comments
|
||||||
coverage:
|
coverage:
|
||||||
status:
|
status:
|
||||||
|
@ -10,3 +9,5 @@ coverage:
|
||||||
default:
|
default:
|
||||||
# GitHub status check is not blocking
|
# GitHub status check is not blocking
|
||||||
informational: true
|
informational: true
|
||||||
|
github_checks:
|
||||||
|
annotations: false
|
||||||
|
|
|
@ -7,8 +7,13 @@ RSpec/Focus: # Require full spec run on CI
|
||||||
Exclude: []
|
Exclude: []
|
||||||
|
|
||||||
Rails/Output: # Remove any `puts` debugging
|
Rails/Output: # Remove any `puts` debugging
|
||||||
|
inherit_mode:
|
||||||
|
merge:
|
||||||
|
- Include
|
||||||
Enabled: true
|
Enabled: true
|
||||||
Exclude: []
|
Exclude: []
|
||||||
|
Include:
|
||||||
|
- spec/**/*.rb
|
||||||
|
|
||||||
Rails/FindEach: # Using `each` could impact performance, use `find_each`
|
Rails/FindEach: # Using `each` could impact performance, use `find_each`
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
|
@ -4,10 +4,11 @@ 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."
|
||||||
|
|
||||||
## [Unreleased] - Unreleased
|
## [v4.3.0-beta.2+cat.1.0.0] - 2024-09-20
|
||||||
|
|
||||||
- Upstream changes
|
- Upstream changes
|
||||||
- Bump Ruby version to 3.3.5
|
- Bumps Ruby version to 3.3.5!
|
||||||
|
- Emoji reactions patch changes
|
||||||
|
|
||||||
## [4.3.0-beta.1+cat.1.0.0] - 2024-08-26
|
## [4.3.0-beta.1+cat.1.0.0] - 2024-08-26
|
||||||
|
|
||||||
|
|
|
@ -10,21 +10,24 @@ The following changelog entries focus on changes visible to users, administrator
|
||||||
|
|
||||||
- **Add confirmation interstitial instead of silently redirecting logged-out visitors to remote resources** (#27792, #28902, and #30651 by @ClearlyClaire and @Gargron)\
|
- **Add confirmation interstitial instead of silently redirecting logged-out visitors to remote resources** (#27792, #28902, and #30651 by @ClearlyClaire and @Gargron)\
|
||||||
This fixes a longstanding open redirect in Mastodon, at the cost of added friction when local links to remote resources are shared.
|
This fixes a longstanding open redirect in Mastodon, at the cost of added friction when local links to remote resources are shared.
|
||||||
|
- Change `form-action` Content-Security-Policy directive to be more restrictive (#26897 by @ClearlyClaire)
|
||||||
|
- Update dependencies
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Add experimental 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, and #31513 by @ClearlyClaire, @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 and #31929 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.\
|
||||||
This feature is intended to eventually replace the existing notifications column, but for this first beta, users will have to enable it in the “Experimental features” section of the notifications column settings.\
|
This feature is intended to eventually replace the existing notifications column, but for this first beta, users will have to enable it in the “Experimental features” section of the notifications column settings.\
|
||||||
The API is not final yet, but it consists of:
|
The API is not final yet, but it consists of:
|
||||||
- a new `group_key` attribute to `Notification` entities
|
- a new `group_key` attribute to `Notification` entities
|
||||||
- `GET /api/v2_alpha/notifications`: https://docs.joinmastodon.org/methods/notifications_alpha/#get-grouped
|
- `GET /api/v2/notifications`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-grouped
|
||||||
- `GET /api/v2_alpha/notifications/:group_key`: https://docs.joinmastodon.org/methods/notifications_alpha/#get-notification-group
|
- `GET /api/v2/notifications/:group_key`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-notification-group
|
||||||
- `POST /api/v2_alpha/notifications/:group_key/dimsiss`: https://docs.joinmastodon.org/methods/notifications_alpha/#dismiss-group
|
- `GET /api/v2/notifications/:group_key/accounts`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts
|
||||||
- `GET /api/v2_alpha/notifications/:unread_count`: https://docs.joinmastodon.org/methods/notifications_alpha/#unread-group-count
|
- `POST /api/v2/notifications/:group_key/dimsiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group
|
||||||
- **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, and #31541 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\
|
- `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)\
|
||||||
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.\
|
||||||
|
@ -57,19 +60,22 @@ The following changelog entries focus on changes visible to users, administrator
|
||||||
- **Add timeline of public posts about a trending link** (#30381 and #30840 by @Gargron)\
|
- **Add timeline of public posts about a trending link** (#30381 and #30840 by @Gargron)\
|
||||||
You can now see public posts mentioning currently-trending articles from people who have opted into discovery features.\
|
You can now see public posts mentioning currently-trending articles from people who have opted into discovery features.\
|
||||||
This adds a new REST API endpoint: https://docs.joinmastodon.org/methods/timelines/#link
|
This adds a new REST API endpoint: https://docs.joinmastodon.org/methods/timelines/#link
|
||||||
- **Add author highlight for news articles whose authors are on the fediverse** (#30398, #30670, #30521, and #30846 by @Gargron)\
|
- **Add author highlight for news articles whose authors are on the fediverse** (#30398, #30670, #30521, #30846, #31819, and #31900 by @Gargron and @oneiros)\
|
||||||
This adds a mechanism to [highlight the author of news articles](https://blog.joinmastodon.org/2024/07/highlighting-journalism-on-mastodon/) shared on Mastodon.\
|
This adds a mechanism to [highlight the author of news articles](https://blog.joinmastodon.org/2024/07/highlighting-journalism-on-mastodon/) shared on Mastodon.\
|
||||||
Articles hosted outside the fediverse can indicate a fediverse author with a meta tag:
|
Articles hosted outside the fediverse can indicate a fediverse author with a meta tag:
|
||||||
```html
|
```html
|
||||||
<meta name="fediverse:creator" content="username@domain" />
|
<meta name="fediverse:creator" content="username@domain" />
|
||||||
```
|
```
|
||||||
On the API side, this is represented by a new `authors` attribute to the `PreviewCard` entity: https://docs.joinmastodon.org/entities/PreviewCard/#authors\
|
On the API side, this is represented by a new `authors` attribute to the `PreviewCard` entity: https://docs.joinmastodon.org/entities/PreviewCard/#authors\
|
||||||
Note that this feature is still work in progress and the tagging format and verification mechanisms may change in future releases.
|
Users can allow arbitrary domains to use `fediverse:creator` to credit them by visiting `/settings/verification`.\
|
||||||
|
This is federated as a new `attributionDomains` property in the `http://joinmastodon.org/ns` namespace, containing an array of domain names: https://docs.joinmastodon.org/spec/activitypub/#properties-used-1
|
||||||
- **Add in-app notifications for moderation actions and warnings** (#30065, #30082, and #30081 by @ClearlyClaire)\
|
- **Add in-app notifications for moderation actions and warnings** (#30065, #30082, and #30081 by @ClearlyClaire)\
|
||||||
In addition to email notifications, also notify users of moderation actions or warnings against them directly within the app, so they are less likely to miss important communication from their moderators.\
|
In addition to email notifications, also notify users of moderation actions or warnings against them directly within the app, so they are less likely to miss important communication from their moderators.\
|
||||||
This adds the `moderation_warning` notification type to the REST API and streaming, with a new [`moderation_warning` attribute](https://docs.joinmastodon.org/entities/Notification/#moderation_warning).
|
This adds the `moderation_warning` notification type to the REST API and streaming, with a new [`moderation_warning` attribute](https://docs.joinmastodon.org/entities/Notification/#moderation_warning).
|
||||||
- **Add domain information to profiles in web UI** (#29602 by @Gargron)\
|
- **Add domain information to profiles in web UI** (#29602 by @Gargron)\
|
||||||
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)\
|
||||||
|
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 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)\
|
||||||
|
@ -77,6 +83,8 @@ The following changelog entries focus on changes visible to users, administrator
|
||||||
- Add `api_versions` to `/api/v2/instance` (#31354 by @ClearlyClaire)\
|
- Add `api_versions` to `/api/v2/instance` (#31354 by @ClearlyClaire)\
|
||||||
Add API version number to make it easier for clients to detect compatible features going forward.\
|
Add API version number to make it easier for clients to detect compatible features going forward.\
|
||||||
See API documentation at https://docs.joinmastodon.org/entities/Instance/#api-versions
|
See API documentation at https://docs.joinmastodon.org/entities/Instance/#api-versions
|
||||||
|
- Add quick links to Administration and Moderation Reports from Web UI (#24838 by @ThisIsMissEm)
|
||||||
|
- Add link to `/admin/roles` in moderation interface when changing someone's role (#31791 by @ClearlyClaire)
|
||||||
- Add recent audit log entries in federation moderation interface (#27386 by @ThisIsMissEm)
|
- Add recent audit log entries in federation moderation interface (#27386 by @ThisIsMissEm)
|
||||||
- Add profile setup to onboarding in web UI (#27829, #27876, and #28453 by @Gargron)
|
- Add profile setup to onboarding in web UI (#27829, #27876, and #28453 by @Gargron)
|
||||||
- Add prominent share/copy button on profiles in web UI (#27865 and #27889 by @ClearlyClaire and @Gargron)
|
- Add prominent share/copy button on profiles in web UI (#27865 and #27889 by @ClearlyClaire and @Gargron)
|
||||||
|
@ -113,21 +121,24 @@ The following changelog entries focus on changes visible to users, administrator
|
||||||
- Add support for multiple `redirect_uris` when creating OAuth 2.0 Applications (#29192 by @ThisIsMissEm)
|
- Add support for multiple `redirect_uris` when creating OAuth 2.0 Applications (#29192 by @ThisIsMissEm)
|
||||||
- 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 validations to `Web::PushSubscription` (#30540 and #30542 by @ThisIsMissEm)
|
|
||||||
- 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, and #30858 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 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, and #30350 by @julianocosta89, @renchap, and @robbkidd)\
|
||||||
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
|
||||||
|
- Add support for CORS to `POST /oauth/revoke` (#31743 by @ClearlyClaire)
|
||||||
- Add redirection back to previous page after site upload deletion (#30141 by @FawazFarid)
|
- Add redirection back to previous page after site upload deletion (#30141 by @FawazFarid)
|
||||||
- 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)
|
||||||
|
@ -156,14 +167,14 @@ 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, and #29659 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, and #31889 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 confirmation modals in the web UI** (#29576, #29614, #29640, #29644, #30131, #30884, and #31399 by @ClearlyClaire, @Gargron, and @tribela)\
|
- **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, and #31510 by @ClearlyClaire, @Gargron, @renchap, and @vmstan)
|
||||||
- **Change onboarding prompt to follow suggestions carousel in web UI** (#28878 and #29272 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, and #29879 by @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)\
|
||||||
|
@ -171,19 +182,28 @@ The following changelog entries focus on changes visible to users, administrator
|
||||||
In addition, the implementation has been significantly reworked, and all follow recommendations are now dismissable.\
|
In addition, the implementation has been significantly reworked, and all follow recommendations are now dismissable.\
|
||||||
This change deprecates the `source` attribute in `Suggestion` entities in the REST API, and replaces it with the new [`sources` attribute](https://docs.joinmastodon.org/entities/Suggestion/#sources).
|
This change deprecates the `source` attribute in `Suggestion` entities in the REST API, and replaces it with the new [`sources` attribute](https://docs.joinmastodon.org/entities/Suggestion/#sources).
|
||||||
- Change account search algorithm (#30803 by @Gargron)
|
- Change account search algorithm (#30803 by @Gargron)
|
||||||
- **Change streaming server to use its own dependencies and its own docker image** (#24702, #27967, #26850, #28112, #28115, #28137, #28138, #28497, #28548, and #30795 by @TheEssem, @ThisIsMissEm, @jippi, @timetinytim, and @vmstan)\
|
- **Change streaming server to use its own dependencies and its own docker image** (#24702, #27967, #26850, #28112, #28115, #28137, #28138, #28497, #28548, #30795, #31612, and #31615 by @TheEssem, @ThisIsMissEm, @jippi, @renchap, @timetinytim, and @vmstan)\
|
||||||
In order to reduce the amount of runtime dependencies, the streaming server has been moved into a separate package and Docker image.\
|
In order to reduce the amount of runtime dependencies, the streaming server has been moved into a separate package and Docker image.\
|
||||||
The `mastodon` image does not contain the streaming server anymore, as it has been moved to its own `mastodon-streaming` image.\
|
The `mastodon` image does not contain the streaming server anymore, as it has been moved to its own `mastodon-streaming` image.\
|
||||||
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 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 embedded posts to use web UI (#31766 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 labels on thread indicators in web UI (#31806 by @Gargron)
|
||||||
|
- Change report action buttons to be disabled when action has already been taken (#31773, #31822, and #31899 by @ClearlyClaire and @ThisIsMissEm)
|
||||||
|
- Change width of columns in advanced web UI (#31762 by @Gargron)
|
||||||
|
- Change design of unread conversations in web UI (#31763 by @Gargron)
|
||||||
- Change Web UI to allow viewing and severing relationships with suspended accounts (#27667 by @ClearlyClaire)\
|
- Change Web UI to allow viewing and severing relationships with suspended accounts (#27667 by @ClearlyClaire)\
|
||||||
This also adds a `with_suspended` parameter to `GET /api/v1/accounts/relationships` in the REST API.
|
This also adds a `with_suspended` parameter to `GET /api/v1/accounts/relationships` in the REST API.
|
||||||
|
- Change preview card image size limit from 2MB to 8MB when using libvips (#31904 by @ClearlyClaire)
|
||||||
- Change avatars border radius (#31390 by @renchap)
|
- Change avatars border radius (#31390 by @renchap)
|
||||||
- Change counters to be displayed on profile timelines in web UI (#30525 by @Gargron)
|
- Change counters to be displayed on profile timelines in web UI (#30525 by @Gargron)
|
||||||
- Change disabled buttons color in light mode to make the difference more visible (#30998 by @renchap)
|
- Change disabled buttons color in light mode to make the difference more visible (#30998 by @renchap)
|
||||||
- Change design of people tab on explore in web UI (#30059 by @Gargron)
|
- Change design of people tab on explore in web UI (#30059 by @Gargron)
|
||||||
- Change sidebar text in web UI (#30696 by @Gargron)
|
- Change sidebar text in web UI (#30696 by @Gargron)
|
||||||
- Change "Follow" to "Follow back" and "Mutual" when appropriate in web UI (#28452 and #28465 by @Gargron and @renchap)
|
- Change "Follow" to "Follow back" and "Mutual" when appropriate in web UI (#28452, #28465, and #31934 by @ClearlyClaire, @Gargron and @renchap)
|
||||||
- Change media to be hidden/blurred by default in report modal (#28522 by @ClearlyClaire)
|
- Change media to be hidden/blurred by default in report modal (#28522 by @ClearlyClaire)
|
||||||
- Change order of the "muting" and "blocking" list options in “Data Exports” (#26088 by @fixermark)
|
- Change order of the "muting" and "blocking" list options in “Data Exports” (#26088 by @fixermark)
|
||||||
- Change admin and moderation notes character limit from 500 to 2000 characters (#30288 by @ThisIsMissEm)
|
- Change admin and moderation notes character limit from 500 to 2000 characters (#30288 by @ThisIsMissEm)
|
||||||
|
@ -197,6 +217,7 @@ The following changelog entries focus on changes visible to users, administrator
|
||||||
- Change dropdown menu icon to not be replaced by close icon when open in web UI (#29532 by @Gargron)
|
- Change dropdown menu icon to not be replaced by close icon when open in web UI (#29532 by @Gargron)
|
||||||
- Change back button to always appear in advanced web UI (#29551 and #29669 by @Gargron)
|
- Change back button to always appear in advanced web UI (#29551 and #29669 by @Gargron)
|
||||||
- Change border of active compose field search inputs (#29832 and #29839 by @vmstan)
|
- Change border of active compose field search inputs (#29832 and #29839 by @vmstan)
|
||||||
|
- Change instances of Nokogiri HTML4 parsing to HTML5 (#31812, #31815, #31813, and #31814 by @flavorjones)
|
||||||
- Change link detection to allow `@` at the end of an URL (#31124 by @adamniedzielski)
|
- Change link detection to allow `@` at the end of an URL (#31124 by @adamniedzielski)
|
||||||
- Change User-Agent to use Mastodon as the product, and http.rb as platform details (#31192 by @ClearlyClaire)
|
- Change User-Agent to use Mastodon as the product, and http.rb as platform details (#31192 by @ClearlyClaire)
|
||||||
- Change layout and wording of the Content Retention server settings page (#27733 by @vmstan)
|
- Change layout and wording of the Content Retention server settings page (#27733 by @vmstan)
|
||||||
|
@ -249,8 +270,17 @@ The following changelog entries focus on changes visible to users, administrator
|
||||||
- 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 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 cross-origin loading of `inert.css` polyfill (#30687 by @louis77)
|
- Fix cross-origin loading of `inert.css` polyfill (#30687 by @louis77)
|
||||||
- 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 `visibility` values in `POST /api/v1/statuses` returning 500 errors (#31571 by @c960657)
|
||||||
|
- Fix some components re-rendering spuriously in web UI (#31879 and #31881 by @ClearlyClaire and @Gargron)
|
||||||
|
- Fix sort order of moderation notes on Reports and Accounts (#31528 by @ThisIsMissEm)
|
||||||
|
- Fix email language when recipient has no selected locale (#31747 by @ClearlyClaire)
|
||||||
|
- Fix frequently-used languages not correctly updating in the web UI (#31386 by @c960657)
|
||||||
|
- Fix `POST /api/v1/statuses` silently ignoring invalid `media_ids` parameter (#31681 by @c960657)
|
||||||
|
- Fix handling of the `BIND` environment variable in the streaming server (#31624 by @ThisIsMissEm)
|
||||||
- Fix empty `aria-hidden` attribute value in logo resources area (#30570 by @mjankowski)
|
- Fix empty `aria-hidden` attribute value in logo resources area (#30570 by @mjankowski)
|
||||||
- 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)
|
||||||
|
|
2
Gemfile
2
Gemfile
|
@ -111,7 +111,7 @@ group :opentelemetry do
|
||||||
gem 'opentelemetry-instrumentation-http', '~> 0.23.2', require: false
|
gem 'opentelemetry-instrumentation-http', '~> 0.23.2', require: false
|
||||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.22.3', require: false
|
gem 'opentelemetry-instrumentation-http_client', '~> 0.22.3', require: false
|
||||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false
|
gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false
|
||||||
gem 'opentelemetry-instrumentation-pg', '~> 0.28.0', require: false
|
gem 'opentelemetry-instrumentation-pg', '~> 0.29.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-rack', '~> 0.24.1', require: false
|
gem 'opentelemetry-instrumentation-rack', '~> 0.24.1', require: false
|
||||||
gem 'opentelemetry-instrumentation-rails', '~> 0.31.0', require: false
|
gem 'opentelemetry-instrumentation-rails', '~> 0.31.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false
|
gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false
|
||||||
|
|
39
Gemfile.lock
39
Gemfile.lock
|
@ -100,17 +100,17 @@ 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.970.0)
|
aws-partitions (1.974.0)
|
||||||
aws-sdk-core (3.203.0)
|
aws-sdk-core (3.205.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.89.0)
|
aws-sdk-kms (1.91.0)
|
||||||
aws-sdk-core (~> 3, >= 3.203.0)
|
aws-sdk-core (~> 3, >= 3.205.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.160.0)
|
aws-sdk-s3 (1.162.0)
|
||||||
aws-sdk-core (~> 3, >= 3.203.0)
|
aws-sdk-core (~> 3, >= 3.205.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.9.1)
|
||||||
|
@ -331,7 +331,7 @@ GEM
|
||||||
httplog (1.7.0)
|
httplog (1.7.0)
|
||||||
rack (>= 2.0)
|
rack (>= 2.0)
|
||||||
rainbow (>= 2.0.0)
|
rainbow (>= 2.0.0)
|
||||||
i18n (1.14.5)
|
i18n (1.14.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
i18n-tasks (1.0.14)
|
i18n-tasks (1.0.14)
|
||||||
activesupport (>= 4.0.2)
|
activesupport (>= 4.0.2)
|
||||||
|
@ -458,7 +458,7 @@ GEM
|
||||||
nokogiri (1.16.7)
|
nokogiri (1.16.7)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oj (3.16.5)
|
oj (3.16.6)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
ostruct (>= 0.2)
|
ostruct (>= 0.2)
|
||||||
omniauth (2.1.2)
|
omniauth (2.1.2)
|
||||||
|
@ -472,9 +472,9 @@ GEM
|
||||||
omniauth-rails_csrf_protection (1.0.2)
|
omniauth-rails_csrf_protection (1.0.2)
|
||||||
actionpack (>= 4.2)
|
actionpack (>= 4.2)
|
||||||
omniauth (~> 2.0)
|
omniauth (~> 2.0)
|
||||||
omniauth-saml (2.1.0)
|
omniauth-saml (2.2.1)
|
||||||
omniauth (~> 2.0)
|
omniauth (~> 2.1)
|
||||||
ruby-saml (~> 1.12)
|
ruby-saml (~> 1.17)
|
||||||
omniauth_openid_connect (0.6.1)
|
omniauth_openid_connect (0.6.1)
|
||||||
omniauth (>= 1.9, < 3)
|
omniauth (>= 1.9, < 3)
|
||||||
openid_connect (~> 1.1)
|
openid_connect (~> 1.1)
|
||||||
|
@ -502,8 +502,8 @@ GEM
|
||||||
opentelemetry-common (~> 0.20)
|
opentelemetry-common (~> 0.20)
|
||||||
opentelemetry-sdk (~> 1.2)
|
opentelemetry-sdk (~> 1.2)
|
||||||
opentelemetry-semantic_conventions
|
opentelemetry-semantic_conventions
|
||||||
opentelemetry-helpers-sql-obfuscation (0.1.0)
|
opentelemetry-helpers-sql-obfuscation (0.2.0)
|
||||||
opentelemetry-common (~> 0.20)
|
opentelemetry-common (~> 0.21)
|
||||||
opentelemetry-instrumentation-action_mailer (0.1.0)
|
opentelemetry-instrumentation-action_mailer (0.1.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.1)
|
opentelemetry-instrumentation-active_support (~> 0.1)
|
||||||
|
@ -528,8 +528,9 @@ GEM
|
||||||
opentelemetry-instrumentation-active_support (0.6.0)
|
opentelemetry-instrumentation-active_support (0.6.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-base (0.22.3)
|
opentelemetry-instrumentation-base (0.22.6)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
|
opentelemetry-common (~> 0.21)
|
||||||
opentelemetry-registry (~> 0.1)
|
opentelemetry-registry (~> 0.1)
|
||||||
opentelemetry-instrumentation-concurrent_ruby (0.21.4)
|
opentelemetry-instrumentation-concurrent_ruby (0.21.4)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
|
@ -549,7 +550,7 @@ GEM
|
||||||
opentelemetry-instrumentation-net_http (0.22.7)
|
opentelemetry-instrumentation-net_http (0.22.7)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-pg (0.28.0)
|
opentelemetry-instrumentation-pg (0.29.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-helpers-sql-obfuscation
|
opentelemetry-helpers-sql-obfuscation
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
|
@ -601,7 +602,7 @@ GEM
|
||||||
actionmailer (>= 3)
|
actionmailer (>= 3)
|
||||||
net-smtp
|
net-smtp
|
||||||
premailer (~> 1.7, >= 1.7.9)
|
premailer (~> 1.7, >= 1.7.9)
|
||||||
propshaft (0.9.1)
|
propshaft (1.0.0)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
|
@ -691,7 +692,7 @@ GEM
|
||||||
redlock (1.3.2)
|
redlock (1.3.2)
|
||||||
redis (>= 3.0.0, < 6.0)
|
redis (>= 3.0.0, < 6.0)
|
||||||
regexp_parser (2.9.2)
|
regexp_parser (2.9.2)
|
||||||
reline (0.5.9)
|
reline (0.5.10)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
request_store (1.6.0)
|
request_store (1.6.0)
|
||||||
rack (>= 1.4)
|
rack (>= 1.4)
|
||||||
|
@ -763,7 +764,7 @@ GEM
|
||||||
rubocop-rspec (~> 3, >= 3.0.1)
|
rubocop-rspec (~> 3, >= 3.0.1)
|
||||||
ruby-prof (1.7.0)
|
ruby-prof (1.7.0)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
ruby-saml (1.16.0)
|
ruby-saml (1.17.0)
|
||||||
nokogiri (>= 1.13.10)
|
nokogiri (>= 1.13.10)
|
||||||
rexml
|
rexml
|
||||||
ruby-vips (2.2.2)
|
ruby-vips (2.2.2)
|
||||||
|
@ -991,7 +992,7 @@ DEPENDENCIES
|
||||||
opentelemetry-instrumentation-http (~> 0.23.2)
|
opentelemetry-instrumentation-http (~> 0.23.2)
|
||||||
opentelemetry-instrumentation-http_client (~> 0.22.3)
|
opentelemetry-instrumentation-http_client (~> 0.22.3)
|
||||||
opentelemetry-instrumentation-net_http (~> 0.22.4)
|
opentelemetry-instrumentation-net_http (~> 0.22.4)
|
||||||
opentelemetry-instrumentation-pg (~> 0.28.0)
|
opentelemetry-instrumentation-pg (~> 0.29.0)
|
||||||
opentelemetry-instrumentation-rack (~> 0.24.1)
|
opentelemetry-instrumentation-rack (~> 0.24.1)
|
||||||
opentelemetry-instrumentation-rails (~> 0.31.0)
|
opentelemetry-instrumentation-rails (~> 0.31.0)
|
||||||
opentelemetry-instrumentation-redis (~> 0.25.3)
|
opentelemetry-instrumentation-redis (~> 0.25.3)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V2Alpha::Notifications::AccountsController < Api::BaseController
|
class Api::V2::Notifications::AccountsController < Api::BaseController
|
||||||
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }
|
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }
|
||||||
before_action :require_user!
|
before_action :require_user!
|
||||||
before_action :set_notifications!
|
before_action :set_notifications!
|
||||||
|
@ -33,11 +33,11 @@ class Api::V2Alpha::Notifications::AccountsController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def next_path
|
def next_path
|
||||||
api_v2_alpha_notification_accounts_url pagination_params(max_id: pagination_max_id) if records_continue?
|
api_v2_notification_accounts_url pagination_params(max_id: pagination_max_id) if records_continue?
|
||||||
end
|
end
|
||||||
|
|
||||||
def prev_path
|
def prev_path
|
||||||
api_v2_alpha_notification_accounts_url pagination_params(min_id: pagination_since_id) unless @paginated_notifications.empty?
|
api_v2_notification_accounts_url pagination_params(min_id: pagination_since_id) unless @paginated_notifications.empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
def pagination_collection
|
def pagination_collection
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V2Alpha::NotificationsController < Api::BaseController
|
class Api::V2::NotificationsController < Api::BaseController
|
||||||
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss]
|
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss]
|
||||||
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss]
|
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss]
|
||||||
before_action :require_user!
|
before_action :require_user!
|
||||||
|
@ -21,7 +21,7 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
|
||||||
ActiveRecord::Associations::Preloader.new(records: @presenter.accounts, associations: [:account_stat, { user: :role }]).call
|
ActiveRecord::Associations::Preloader.new(records: @presenter.accounts, associations: [:account_stat, { user: :role }]).call
|
||||||
end
|
end
|
||||||
|
|
||||||
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#index rendering') do |span|
|
MastodonOTELTracer.in_span('Api::V2::NotificationsController#index rendering') do |span|
|
||||||
statuses = @grouped_notifications.filter_map { |group| group.target_status&.id }
|
statuses = @grouped_notifications.filter_map { |group| group.target_status&.id }
|
||||||
|
|
||||||
span.add_attributes(
|
span.add_attributes(
|
||||||
|
@ -64,7 +64,7 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
|
||||||
private
|
private
|
||||||
|
|
||||||
def load_notifications
|
def load_notifications
|
||||||
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_notifications') do
|
MastodonOTELTracer.in_span('Api::V2::NotificationsController#load_notifications') do
|
||||||
notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id(
|
notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id(
|
||||||
limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
|
limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
|
||||||
params.slice(:max_id, :since_id, :min_id, :grouped_types).permit(:max_id, :since_id, :min_id, grouped_types: [])
|
params.slice(:max_id, :since_id, :min_id, :grouped_types).permit(:max_id, :since_id, :min_id, grouped_types: [])
|
||||||
|
@ -79,7 +79,7 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
|
||||||
def load_grouped_notifications
|
def load_grouped_notifications
|
||||||
return [] if @notifications.empty?
|
return [] if @notifications.empty?
|
||||||
|
|
||||||
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_grouped_notifications') do
|
MastodonOTELTracer.in_span('Api::V2::NotificationsController#load_grouped_notifications') do
|
||||||
NotificationGroup.from_notifications(@notifications, pagination_range: (@notifications.last.id)..(@notifications.first.id), grouped_types: params[:grouped_types])
|
NotificationGroup.from_notifications(@notifications, pagination_range: (@notifications.last.id)..(@notifications.first.id), grouped_types: params[:grouped_types])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -101,11 +101,11 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def next_path
|
def next_path
|
||||||
api_v2_alpha_notifications_url pagination_params(max_id: pagination_max_id) unless @notifications.empty?
|
api_v2_notifications_url pagination_params(max_id: pagination_max_id) unless @notifications.empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
def prev_path
|
def prev_path
|
||||||
api_v2_alpha_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty?
|
api_v2_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
def pagination_collection
|
def pagination_collection
|
|
@ -8,6 +8,16 @@ module WebAppControllerConcern
|
||||||
|
|
||||||
before_action :redirect_unauthenticated_to_permalinks!
|
before_action :redirect_unauthenticated_to_permalinks!
|
||||||
before_action :set_app_body_class
|
before_action :set_app_body_class
|
||||||
|
|
||||||
|
content_security_policy do |p|
|
||||||
|
policy = ContentSecurityPolicy.new
|
||||||
|
|
||||||
|
if policy.sso_host.present?
|
||||||
|
p.form_action policy.sso_host
|
||||||
|
else
|
||||||
|
p.form_action :none
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def skip_csrf_meta_tags?
|
def skip_csrf_meta_tags?
|
||||||
|
|
|
@ -4,7 +4,6 @@ class Redirect::BaseController < ApplicationController
|
||||||
vary_by 'Accept-Language'
|
vary_by 'Accept-Language'
|
||||||
|
|
||||||
before_action :set_resource
|
before_action :set_resource
|
||||||
before_action :set_app_body_class
|
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@redirect_path = ActivityPub::TagManager.instance.url_for(@resource)
|
@redirect_path = ActivityPub::TagManager.instance.url_for(@resource)
|
||||||
|
@ -14,10 +13,6 @@ class Redirect::BaseController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_app_body_class
|
|
||||||
@body_classes = 'app-body'
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_resource
|
def set_resource
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,14 +2,30 @@
|
||||||
|
|
||||||
class Settings::VerificationsController < Settings::BaseController
|
class Settings::VerificationsController < Settings::BaseController
|
||||||
before_action :set_account
|
before_action :set_account
|
||||||
|
before_action :set_verified_links
|
||||||
|
|
||||||
def show
|
def show; end
|
||||||
@verified_links = @account.fields.select(&:verified?)
|
|
||||||
|
def update
|
||||||
|
if UpdateAccountService.new.call(@account, account_params)
|
||||||
|
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
|
||||||
|
redirect_to settings_verification_path, notice: I18n.t('generic.changes_saved_msg')
|
||||||
|
else
|
||||||
|
render :show
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def account_params
|
||||||
|
params.require(:account).permit(:attribution_domains_as_text)
|
||||||
|
end
|
||||||
|
|
||||||
def set_account
|
def set_account
|
||||||
@account = current_account
|
@account = current_account
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_verified_links
|
||||||
|
@verified_links = @account.fields.select(&:verified?)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,7 +11,6 @@ class StatusesController < ApplicationController
|
||||||
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
|
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
|
||||||
before_action :set_status
|
before_action :set_status
|
||||||
before_action :redirect_to_original, only: :show
|
before_action :redirect_to_original, only: :show
|
||||||
before_action :set_body_classes, only: :embed
|
|
||||||
|
|
||||||
after_action :set_link_headers
|
after_action :set_link_headers
|
||||||
|
|
||||||
|
@ -51,10 +50,6 @@ class StatusesController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_body_classes
|
|
||||||
@body_classes = 'with-modals'
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_link_headers
|
def set_link_headers
|
||||||
response.headers['Link'] = LinkHeader.new(
|
response.headers['Link'] = LinkHeader.new(
|
||||||
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]
|
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]
|
||||||
|
|
|
@ -19,14 +19,6 @@ module AccountsHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_action_button(account)
|
|
||||||
return if account.memorial? || account.moved?
|
|
||||||
|
|
||||||
link_to ActivityPub::TagManager.instance.url_for(account), class: 'button logo-button', target: '_new' do
|
|
||||||
safe_join([logo_as_symbol, t('accounts.follow')])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def hide_followers_count?(account)
|
def hide_followers_count?(account)
|
||||||
Setting.hide_followers_count || account.user&.settings&.[]('hide_followers_count')
|
Setting.hide_followers_count || account.user&.settings&.[]('hide_followers_count')
|
||||||
end
|
end
|
||||||
|
|
|
@ -42,6 +42,7 @@ module ContextHelper
|
||||||
'cipherText' => 'toot:cipherText',
|
'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' } },
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def full_context
|
def full_context
|
||||||
|
|
|
@ -57,26 +57,6 @@ module MediaComponentHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_card_component(status, **options)
|
|
||||||
component_params = {
|
|
||||||
sensitive: sensitive_viewer?(status, current_account),
|
|
||||||
card: serialize_status_card(status).as_json,
|
|
||||||
}.merge(**options)
|
|
||||||
|
|
||||||
react_component :card, component_params
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_poll_component(status, **options)
|
|
||||||
component_params = {
|
|
||||||
disabled: true,
|
|
||||||
poll: serialize_status_poll(status).as_json,
|
|
||||||
}.merge(**options)
|
|
||||||
|
|
||||||
react_component :poll, component_params do
|
|
||||||
render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: prefers_autoplay? }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def serialize_media_attachment(attachment)
|
def serialize_media_attachment(attachment)
|
||||||
|
@ -86,22 +66,6 @@ module MediaComponentHelper
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def serialize_status_card(status)
|
|
||||||
ActiveModelSerializers::SerializableResource.new(
|
|
||||||
status.preview_card,
|
|
||||||
serializer: REST::PreviewCardSerializer
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def serialize_status_poll(status)
|
|
||||||
ActiveModelSerializers::SerializableResource.new(
|
|
||||||
status.preloadable_poll,
|
|
||||||
serializer: REST::PollSerializer,
|
|
||||||
scope: current_user,
|
|
||||||
scope_name: :current_user
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def sensitive_viewer?(status, account)
|
def sensitive_viewer?(status, account)
|
||||||
if !account.nil? && account.id == status.account_id
|
if !account.nil? && account.id == status.account_id
|
||||||
status.sensitive
|
status.sensitive
|
||||||
|
|
|
@ -4,6 +4,13 @@ module StatusesHelper
|
||||||
EMBEDDED_CONTROLLER = 'statuses'
|
EMBEDDED_CONTROLLER = 'statuses'
|
||||||
EMBEDDED_ACTION = 'embed'
|
EMBEDDED_ACTION = 'embed'
|
||||||
|
|
||||||
|
VISIBLITY_ICONS = {
|
||||||
|
public: 'globe',
|
||||||
|
unlisted: 'lock_open',
|
||||||
|
private: 'lock',
|
||||||
|
direct: 'alternate_email',
|
||||||
|
}.freeze
|
||||||
|
|
||||||
def nothing_here(extra_classes = '')
|
def nothing_here(extra_classes = '')
|
||||||
content_tag(:div, class: "nothing-here #{extra_classes}") do
|
content_tag(:div, class: "nothing-here #{extra_classes}") do
|
||||||
t('accounts.nothing_here')
|
t('accounts.nothing_here')
|
||||||
|
@ -57,17 +64,8 @@ module StatusesHelper
|
||||||
embedded_view? ? '_blank' : nil
|
embedded_view? ? '_blank' : nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def fa_visibility_icon(status)
|
def visibility_icon(status)
|
||||||
case status.visibility
|
VISIBLITY_ICONS[status.visibility.to_sym]
|
||||||
when 'public'
|
|
||||||
material_symbol 'globe'
|
|
||||||
when 'unlisted'
|
|
||||||
material_symbol 'lock_open'
|
|
||||||
when 'private'
|
|
||||||
material_symbol 'lock'
|
|
||||||
when 'direct'
|
|
||||||
material_symbol 'alternate_email'
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def embedded_view?
|
def embedded_view?
|
||||||
|
|
74
app/javascript/entrypoints/embed.tsx
Normal file
74
app/javascript/entrypoints/embed.tsx
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import './public-path';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import { afterInitialRender } from 'mastodon/../hooks/useRenderSignal';
|
||||||
|
|
||||||
|
import { start } from '../mastodon/common';
|
||||||
|
import { Status } from '../mastodon/features/standalone/status';
|
||||||
|
import { loadPolyfills } from '../mastodon/polyfills';
|
||||||
|
import ready from '../mastodon/ready';
|
||||||
|
|
||||||
|
start();
|
||||||
|
|
||||||
|
function loaded() {
|
||||||
|
const mountNode = document.getElementById('mastodon-status');
|
||||||
|
|
||||||
|
if (mountNode) {
|
||||||
|
const attr = mountNode.getAttribute('data-props');
|
||||||
|
|
||||||
|
if (!attr) return;
|
||||||
|
|
||||||
|
const props = JSON.parse(attr) as { id: string; locale: string };
|
||||||
|
const root = createRoot(mountNode);
|
||||||
|
|
||||||
|
root.render(<Status {...props} />);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
ready(loaded).catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPolyfills()
|
||||||
|
.then(main)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SetHeightMessage {
|
||||||
|
type: 'setHeight';
|
||||||
|
id: string;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSetHeightMessage(data: unknown): data is SetHeightMessage {
|
||||||
|
if (
|
||||||
|
data &&
|
||||||
|
typeof data === 'object' &&
|
||||||
|
'type' in data &&
|
||||||
|
data.type === 'setHeight'
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
else return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', (e) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases
|
||||||
|
if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return;
|
||||||
|
|
||||||
|
const data = e.data;
|
||||||
|
|
||||||
|
// We use a timeout to allow for the React page to render before calculating the height
|
||||||
|
afterInitialRender(() => {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
type: 'setHeight',
|
||||||
|
id: data.id,
|
||||||
|
height: document.getElementsByTagName('html')[0]?.scrollHeight,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -37,43 +37,6 @@ const messages = defineMessages({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SetHeightMessage {
|
|
||||||
type: 'setHeight';
|
|
||||||
id: string;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSetHeightMessage(data: unknown): data is SetHeightMessage {
|
|
||||||
if (
|
|
||||||
data &&
|
|
||||||
typeof data === 'object' &&
|
|
||||||
'type' in data &&
|
|
||||||
data.type === 'setHeight'
|
|
||||||
)
|
|
||||||
return true;
|
|
||||||
else return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('message', (e) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases
|
|
||||||
if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return;
|
|
||||||
|
|
||||||
const data = e.data;
|
|
||||||
|
|
||||||
ready(() => {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: 'setHeight',
|
|
||||||
id: data.id,
|
|
||||||
height: document.getElementsByTagName('html')[0]?.scrollHeight,
|
|
||||||
},
|
|
||||||
'*',
|
|
||||||
);
|
|
||||||
}).catch((e: unknown) => {
|
|
||||||
console.error('Error in setHeightMessage postMessage', e);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function loaded() {
|
function loaded() {
|
||||||
const { messages: localeData } = getLocale();
|
const { messages: localeData } = getLocale();
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { debounce } from 'lodash';
|
||||||
|
|
||||||
import type { MarkerJSON } from 'flavours/glitch/api_types/markers';
|
import type { MarkerJSON } from 'flavours/glitch/api_types/markers';
|
||||||
import { getAccessToken } from 'flavours/glitch/initial_state';
|
import { getAccessToken } from 'flavours/glitch/initial_state';
|
||||||
import { selectUseGroupedNotifications } from 'flavours/glitch/selectors/settings';
|
|
||||||
import type { AppDispatch, RootState } from 'flavours/glitch/store';
|
import type { AppDispatch, RootState } from 'flavours/glitch/store';
|
||||||
import { createAppAsyncThunk } from 'flavours/glitch/store/typed_functions';
|
import { createAppAsyncThunk } from 'flavours/glitch/store/typed_functions';
|
||||||
|
|
||||||
|
@ -65,7 +64,7 @@ export const synchronouslySubmitMarkers = createAppAsyncThunk(
|
||||||
client.setRequestHeader('Content-Type', 'application/json');
|
client.setRequestHeader('Content-Type', 'application/json');
|
||||||
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
||||||
client.send(JSON.stringify(params));
|
client.send(JSON.stringify(params));
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Do not make the BeforeUnload handler error out
|
// Do not make the BeforeUnload handler error out
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -76,12 +75,7 @@ interface MarkerParam {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLastNotificationId(state: RootState): string | undefined {
|
function getLastNotificationId(state: RootState): string | undefined {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
return state.notificationGroups.lastReadId;
|
||||||
return selectUseGroupedNotifications(state)
|
|
||||||
? state.notificationGroups.lastReadId
|
|
||||||
: // @ts-expect-error state.notifications is not yet typed
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
||||||
state.getIn(['notifications', 'lastReadId']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildPostMarkersParams = (state: RootState) => {
|
const buildPostMarkersParams = (state: RootState) => {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { createAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
apiClearNotifications,
|
apiClearNotifications,
|
||||||
apiFetchNotifications,
|
apiFetchNotificationGroups,
|
||||||
} from 'flavours/glitch/api/notifications';
|
} from 'flavours/glitch/api/notifications';
|
||||||
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
|
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
|
||||||
import type {
|
import type {
|
||||||
|
@ -71,7 +71,7 @@ function dispatchAssociatedRecords(
|
||||||
export const fetchNotifications = createDataLoadingThunk(
|
export const fetchNotifications = createDataLoadingThunk(
|
||||||
'notificationGroups/fetch',
|
'notificationGroups/fetch',
|
||||||
async (_params, { getState }) =>
|
async (_params, { getState }) =>
|
||||||
apiFetchNotifications({ exclude_types: getExcludedTypes(getState()) }),
|
apiFetchNotificationGroups({ exclude_types: getExcludedTypes(getState()) }),
|
||||||
({ notifications, accounts, statuses }, { dispatch }) => {
|
({ notifications, accounts, statuses }, { dispatch }) => {
|
||||||
dispatch(importFetchedAccounts(accounts));
|
dispatch(importFetchedAccounts(accounts));
|
||||||
dispatch(importFetchedStatuses(statuses));
|
dispatch(importFetchedStatuses(statuses));
|
||||||
|
@ -92,7 +92,7 @@ export const fetchNotifications = createDataLoadingThunk(
|
||||||
export const fetchNotificationsGap = createDataLoadingThunk(
|
export const fetchNotificationsGap = createDataLoadingThunk(
|
||||||
'notificationGroups/fetchGap',
|
'notificationGroups/fetchGap',
|
||||||
async (params: { gap: NotificationGap }, { getState }) =>
|
async (params: { gap: NotificationGap }, { getState }) =>
|
||||||
apiFetchNotifications({
|
apiFetchNotificationGroups({
|
||||||
max_id: params.gap.maxId,
|
max_id: params.gap.maxId,
|
||||||
exclude_types: getExcludedTypes(getState()),
|
exclude_types: getExcludedTypes(getState()),
|
||||||
}),
|
}),
|
||||||
|
@ -108,7 +108,7 @@ export const fetchNotificationsGap = createDataLoadingThunk(
|
||||||
export const pollRecentNotifications = createDataLoadingThunk(
|
export const pollRecentNotifications = createDataLoadingThunk(
|
||||||
'notificationGroups/pollRecentNotifications',
|
'notificationGroups/pollRecentNotifications',
|
||||||
async (_params, { getState }) => {
|
async (_params, { getState }) => {
|
||||||
return apiFetchNotifications({
|
return apiFetchNotificationGroups({
|
||||||
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
|
||||||
|
|
234
app/javascript/flavours/glitch/actions/notification_requests.ts
Normal file
234
app/javascript/flavours/glitch/actions/notification_requests.ts
Normal file
|
@ -0,0 +1,234 @@
|
||||||
|
import {
|
||||||
|
apiFetchNotificationRequest,
|
||||||
|
apiFetchNotificationRequests,
|
||||||
|
apiFetchNotifications,
|
||||||
|
apiAcceptNotificationRequest,
|
||||||
|
apiDismissNotificationRequest,
|
||||||
|
apiAcceptNotificationRequests,
|
||||||
|
apiDismissNotificationRequests,
|
||||||
|
} from 'flavours/glitch/api/notifications';
|
||||||
|
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
|
||||||
|
import type {
|
||||||
|
ApiNotificationGroupJSON,
|
||||||
|
ApiNotificationJSON,
|
||||||
|
} from 'flavours/glitch/api_types/notifications';
|
||||||
|
import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses';
|
||||||
|
import type { AppDispatch, RootState } from 'flavours/glitch/store';
|
||||||
|
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
|
||||||
|
|
||||||
|
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||||
|
import { decreasePendingNotificationsCount } from './notification_policies';
|
||||||
|
|
||||||
|
// TODO: refactor with notification_groups
|
||||||
|
function dispatchAssociatedRecords(
|
||||||
|
dispatch: AppDispatch,
|
||||||
|
notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[],
|
||||||
|
) {
|
||||||
|
const fetchedAccounts: ApiAccountJSON[] = [];
|
||||||
|
const fetchedStatuses: ApiStatusJSON[] = [];
|
||||||
|
|
||||||
|
notifications.forEach((notification) => {
|
||||||
|
if (notification.type === 'admin.report') {
|
||||||
|
fetchedAccounts.push(notification.report.target_account);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.type === 'moderation_warning') {
|
||||||
|
fetchedAccounts.push(notification.moderation_warning.target_account);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('status' in notification && notification.status) {
|
||||||
|
fetchedStatuses.push(notification.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fetchedAccounts.length > 0)
|
||||||
|
dispatch(importFetchedAccounts(fetchedAccounts));
|
||||||
|
|
||||||
|
if (fetchedStatuses.length > 0)
|
||||||
|
dispatch(importFetchedStatuses(fetchedStatuses));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchNotificationRequests = createDataLoadingThunk(
|
||||||
|
'notificationRequests/fetch',
|
||||||
|
async (_params, { getState }) => {
|
||||||
|
let sinceId = undefined;
|
||||||
|
|
||||||
|
if (getState().notificationRequests.items.length > 0) {
|
||||||
|
sinceId = getState().notificationRequests.items[0]?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiFetchNotificationRequests({
|
||||||
|
since_id: sinceId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
({ requests, links }, { dispatch }) => {
|
||||||
|
const next = links.refs.find((link) => link.rel === 'next');
|
||||||
|
|
||||||
|
dispatch(importFetchedAccounts(requests.map((request) => request.account)));
|
||||||
|
|
||||||
|
return { requests, next: next?.uri };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: (_params, { getState }) =>
|
||||||
|
!getState().notificationRequests.isLoading,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchNotificationRequest = createDataLoadingThunk(
|
||||||
|
'notificationRequest/fetch',
|
||||||
|
async ({ id }: { id: string }) => apiFetchNotificationRequest(id),
|
||||||
|
{
|
||||||
|
condition: ({ id }, { getState }) =>
|
||||||
|
!(
|
||||||
|
getState().notificationRequests.current.item?.id === id ||
|
||||||
|
getState().notificationRequests.current.isLoading
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const expandNotificationRequests = createDataLoadingThunk(
|
||||||
|
'notificationRequests/expand',
|
||||||
|
async (_, { getState }) => {
|
||||||
|
const nextUrl = getState().notificationRequests.next;
|
||||||
|
if (!nextUrl) throw new Error('missing URL');
|
||||||
|
|
||||||
|
return apiFetchNotificationRequests(undefined, nextUrl);
|
||||||
|
},
|
||||||
|
({ requests, links }, { dispatch }) => {
|
||||||
|
const next = links.refs.find((link) => link.rel === 'next');
|
||||||
|
|
||||||
|
dispatch(importFetchedAccounts(requests.map((request) => request.account)));
|
||||||
|
|
||||||
|
return { requests, next: next?.uri };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: (_, { getState }) =>
|
||||||
|
!!getState().notificationRequests.next &&
|
||||||
|
!getState().notificationRequests.isLoading,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchNotificationsForRequest = createDataLoadingThunk(
|
||||||
|
'notificationRequest/fetchNotifications',
|
||||||
|
async ({ accountId }: { accountId: string }, { getState }) => {
|
||||||
|
const sinceId =
|
||||||
|
// @ts-expect-error current.notifications.items is not yet typed
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||||
|
getState().notificationRequests.current.notifications.items[0]?.get(
|
||||||
|
'id',
|
||||||
|
) as string | undefined;
|
||||||
|
|
||||||
|
return apiFetchNotifications({
|
||||||
|
since_id: sinceId,
|
||||||
|
account_id: accountId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
({ notifications, links }, { dispatch }) => {
|
||||||
|
const next = links.refs.find((link) => link.rel === 'next');
|
||||||
|
|
||||||
|
dispatchAssociatedRecords(dispatch, notifications);
|
||||||
|
|
||||||
|
return { notifications, next: next?.uri };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: ({ accountId }, { getState }) => {
|
||||||
|
const current = getState().notificationRequests.current;
|
||||||
|
return !(
|
||||||
|
current.item?.account_id === accountId &&
|
||||||
|
current.notifications.isLoading
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const expandNotificationsForRequest = createDataLoadingThunk(
|
||||||
|
'notificationRequest/expandNotifications',
|
||||||
|
async (_, { getState }) => {
|
||||||
|
const nextUrl = getState().notificationRequests.current.notifications.next;
|
||||||
|
if (!nextUrl) throw new Error('missing URL');
|
||||||
|
|
||||||
|
return apiFetchNotifications(undefined, nextUrl);
|
||||||
|
},
|
||||||
|
({ notifications, links }, { dispatch }) => {
|
||||||
|
const next = links.refs.find((link) => link.rel === 'next');
|
||||||
|
|
||||||
|
dispatchAssociatedRecords(dispatch, notifications);
|
||||||
|
|
||||||
|
return { notifications, next: next?.uri };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: ({ accountId }: { accountId: string }, { getState }) => {
|
||||||
|
const url = getState().notificationRequests.current.notifications.next;
|
||||||
|
|
||||||
|
return (
|
||||||
|
!!url &&
|
||||||
|
!getState().notificationRequests.current.notifications.isLoading &&
|
||||||
|
getState().notificationRequests.current.item?.account_id === accountId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectNotificationCountForRequest = (state: RootState, id: string) => {
|
||||||
|
const requests = state.notificationRequests.items;
|
||||||
|
const thisRequest = requests.find((request) => request.id === id);
|
||||||
|
return thisRequest ? thisRequest.notifications_count : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const acceptNotificationRequest = createDataLoadingThunk(
|
||||||
|
'notificationRequest/accept',
|
||||||
|
({ id }: { id: string }) => apiAcceptNotificationRequest(id),
|
||||||
|
(_data, { dispatch, getState, discardLoadData, actionArg: { id } }) => {
|
||||||
|
const count = selectNotificationCountForRequest(getState(), id);
|
||||||
|
|
||||||
|
dispatch(decreasePendingNotificationsCount(count));
|
||||||
|
|
||||||
|
// The payload is not used in any functions
|
||||||
|
return discardLoadData;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dismissNotificationRequest = createDataLoadingThunk(
|
||||||
|
'notificationRequest/dismiss',
|
||||||
|
({ id }: { id: string }) => apiDismissNotificationRequest(id),
|
||||||
|
(_data, { dispatch, getState, discardLoadData, actionArg: { id } }) => {
|
||||||
|
const count = selectNotificationCountForRequest(getState(), id);
|
||||||
|
|
||||||
|
dispatch(decreasePendingNotificationsCount(count));
|
||||||
|
|
||||||
|
// The payload is not used in any functions
|
||||||
|
return discardLoadData;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const acceptNotificationRequests = createDataLoadingThunk(
|
||||||
|
'notificationRequests/acceptBulk',
|
||||||
|
({ ids }: { ids: string[] }) => apiAcceptNotificationRequests(ids),
|
||||||
|
(_data, { dispatch, getState, discardLoadData, actionArg: { ids } }) => {
|
||||||
|
const count = ids.reduce(
|
||||||
|
(count, id) => count + selectNotificationCountForRequest(getState(), id),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch(decreasePendingNotificationsCount(count));
|
||||||
|
|
||||||
|
// The payload is not used in any functions
|
||||||
|
return discardLoadData;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dismissNotificationRequests = createDataLoadingThunk(
|
||||||
|
'notificationRequests/dismissBulk',
|
||||||
|
({ ids }: { ids: string[] }) => apiDismissNotificationRequests(ids),
|
||||||
|
(_data, { dispatch, getState, discardLoadData, actionArg: { ids } }) => {
|
||||||
|
const count = ids.reduce(
|
||||||
|
(count, id) => count + selectNotificationCountForRequest(getState(), id),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch(decreasePendingNotificationsCount(count));
|
||||||
|
|
||||||
|
// The payload is not used in any functions
|
||||||
|
return discardLoadData;
|
||||||
|
},
|
||||||
|
);
|
|
@ -18,7 +18,6 @@ import {
|
||||||
importFetchedStatuses,
|
importFetchedStatuses,
|
||||||
} from './importer';
|
} from './importer';
|
||||||
import { submitMarkers } from './markers';
|
import { submitMarkers } from './markers';
|
||||||
import { decreasePendingNotificationsCount } from './notification_policies';
|
|
||||||
import { notificationsUpdate } from "./notifications_typed";
|
import { notificationsUpdate } from "./notifications_typed";
|
||||||
import { register as registerPushNotifications } from './push_notifications';
|
import { register as registerPushNotifications } from './push_notifications';
|
||||||
import { saveSettings } from './settings';
|
import { saveSettings } from './settings';
|
||||||
|
@ -57,26 +56,6 @@ export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
|
||||||
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
|
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
|
||||||
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
|
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
|
||||||
|
|
||||||
export const NOTIFICATION_REQUESTS_FETCH_REQUEST = 'NOTIFICATION_REQUESTS_FETCH_REQUEST';
|
|
||||||
export const NOTIFICATION_REQUESTS_FETCH_SUCCESS = 'NOTIFICATION_REQUESTS_FETCH_SUCCESS';
|
|
||||||
export const NOTIFICATION_REQUESTS_FETCH_FAIL = 'NOTIFICATION_REQUESTS_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATION_REQUESTS_EXPAND_REQUEST = 'NOTIFICATION_REQUESTS_EXPAND_REQUEST';
|
|
||||||
export const NOTIFICATION_REQUESTS_EXPAND_SUCCESS = 'NOTIFICATION_REQUESTS_EXPAND_SUCCESS';
|
|
||||||
export const NOTIFICATION_REQUESTS_EXPAND_FAIL = 'NOTIFICATION_REQUESTS_EXPAND_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATION_REQUEST_FETCH_REQUEST = 'NOTIFICATION_REQUEST_FETCH_REQUEST';
|
|
||||||
export const NOTIFICATION_REQUEST_FETCH_SUCCESS = 'NOTIFICATION_REQUEST_FETCH_SUCCESS';
|
|
||||||
export const NOTIFICATION_REQUEST_FETCH_FAIL = 'NOTIFICATION_REQUEST_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATION_REQUEST_ACCEPT_REQUEST = 'NOTIFICATION_REQUEST_ACCEPT_REQUEST';
|
|
||||||
export const NOTIFICATION_REQUEST_ACCEPT_SUCCESS = 'NOTIFICATION_REQUEST_ACCEPT_SUCCESS';
|
|
||||||
export const NOTIFICATION_REQUEST_ACCEPT_FAIL = 'NOTIFICATION_REQUEST_ACCEPT_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMISS_REQUEST';
|
|
||||||
export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS';
|
|
||||||
export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST';
|
export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST';
|
||||||
export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS';
|
export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS';
|
||||||
export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL';
|
export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL';
|
||||||
|
@ -85,14 +64,6 @@ export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISM
|
||||||
export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS';
|
export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS';
|
||||||
export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL';
|
export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL';
|
||||||
|
|
||||||
export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST';
|
|
||||||
export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS';
|
|
||||||
export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST';
|
|
||||||
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS';
|
|
||||||
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL';
|
|
||||||
|
|
||||||
defineMessages({
|
defineMessages({
|
||||||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
||||||
});
|
});
|
||||||
|
@ -105,12 +76,6 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectNotificationCountForRequest = (state, id) => {
|
|
||||||
const requests = state.getIn(['notificationRequests', 'items']);
|
|
||||||
const thisRequest = requests.find(request => request.get('id') === id);
|
|
||||||
return thisRequest ? thisRequest.get('notifications_count') : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const loadPending = () => ({
|
export const loadPending = () => ({
|
||||||
type: NOTIFICATIONS_LOAD_PENDING,
|
type: NOTIFICATIONS_LOAD_PENDING,
|
||||||
});
|
});
|
||||||
|
@ -432,296 +397,3 @@ export function setBrowserPermission (value) {
|
||||||
value,
|
value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchNotificationRequests = () => (dispatch, getState) => {
|
|
||||||
const params = {};
|
|
||||||
|
|
||||||
if (getState().getIn(['notificationRequests', 'isLoading'])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (getState().getIn(['notificationRequests', 'items'])?.size > 0) {
|
|
||||||
params.since_id = getState().getIn(['notificationRequests', 'items', 0, 'id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(fetchNotificationRequestsRequest());
|
|
||||||
|
|
||||||
api().get('/api/v1/notifications/requests', { params }).then(response => {
|
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
|
||||||
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
|
|
||||||
dispatch(fetchNotificationRequestsSuccess(response.data, next ? next.uri : null));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(fetchNotificationRequestsFail(err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchNotificationRequestsRequest = () => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_FETCH_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationRequestsSuccess = (requests, next) => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_FETCH_SUCCESS,
|
|
||||||
requests,
|
|
||||||
next,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationRequestsFail = error => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_FETCH_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandNotificationRequests = () => (dispatch, getState) => {
|
|
||||||
const url = getState().getIn(['notificationRequests', 'next']);
|
|
||||||
|
|
||||||
if (!url || getState().getIn(['notificationRequests', 'isLoading'])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(expandNotificationRequestsRequest());
|
|
||||||
|
|
||||||
api().get(url).then(response => {
|
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
|
||||||
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
|
|
||||||
dispatch(expandNotificationRequestsSuccess(response.data, next?.uri));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(expandNotificationRequestsFail(err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const expandNotificationRequestsRequest = () => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_EXPAND_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandNotificationRequestsSuccess = (requests, next) => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_EXPAND_SUCCESS,
|
|
||||||
requests,
|
|
||||||
next,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandNotificationRequestsFail = error => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_EXPAND_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationRequest = id => (dispatch, getState) => {
|
|
||||||
const current = getState().getIn(['notificationRequests', 'current']);
|
|
||||||
|
|
||||||
if (current.getIn(['item', 'id']) === id || current.get('isLoading')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(fetchNotificationRequestRequest(id));
|
|
||||||
|
|
||||||
api().get(`/api/v1/notifications/requests/${id}`).then(({ data }) => {
|
|
||||||
dispatch(fetchNotificationRequestSuccess(data));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(fetchNotificationRequestFail(id, err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchNotificationRequestRequest = id => ({
|
|
||||||
type: NOTIFICATION_REQUEST_FETCH_REQUEST,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationRequestSuccess = request => ({
|
|
||||||
type: NOTIFICATION_REQUEST_FETCH_SUCCESS,
|
|
||||||
request,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationRequestFail = (id, error) => ({
|
|
||||||
type: NOTIFICATION_REQUEST_FETCH_FAIL,
|
|
||||||
id,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const acceptNotificationRequest = (id) => (dispatch, getState) => {
|
|
||||||
const count = selectNotificationCountForRequest(getState(), id);
|
|
||||||
dispatch(acceptNotificationRequestRequest(id));
|
|
||||||
|
|
||||||
api().post(`/api/v1/notifications/requests/${id}/accept`).then(() => {
|
|
||||||
dispatch(acceptNotificationRequestSuccess(id));
|
|
||||||
dispatch(decreasePendingNotificationsCount(count));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(acceptNotificationRequestFail(id, err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const acceptNotificationRequestRequest = id => ({
|
|
||||||
type: NOTIFICATION_REQUEST_ACCEPT_REQUEST,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const acceptNotificationRequestSuccess = id => ({
|
|
||||||
type: NOTIFICATION_REQUEST_ACCEPT_SUCCESS,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const acceptNotificationRequestFail = (id, error) => ({
|
|
||||||
type: NOTIFICATION_REQUEST_ACCEPT_FAIL,
|
|
||||||
id,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dismissNotificationRequest = (id) => (dispatch, getState) => {
|
|
||||||
const count = selectNotificationCountForRequest(getState(), id);
|
|
||||||
dispatch(dismissNotificationRequestRequest(id));
|
|
||||||
|
|
||||||
api().post(`/api/v1/notifications/requests/${id}/dismiss`).then(() =>{
|
|
||||||
dispatch(dismissNotificationRequestSuccess(id));
|
|
||||||
dispatch(decreasePendingNotificationsCount(count));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(dismissNotificationRequestFail(id, err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dismissNotificationRequestRequest = id => ({
|
|
||||||
type: NOTIFICATION_REQUEST_DISMISS_REQUEST,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dismissNotificationRequestSuccess = id => ({
|
|
||||||
type: NOTIFICATION_REQUEST_DISMISS_SUCCESS,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dismissNotificationRequestFail = (id, error) => ({
|
|
||||||
type: NOTIFICATION_REQUEST_DISMISS_FAIL,
|
|
||||||
id,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const acceptNotificationRequests = (ids) => (dispatch, getState) => {
|
|
||||||
const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0);
|
|
||||||
dispatch(acceptNotificationRequestsRequest(ids));
|
|
||||||
|
|
||||||
api().post(`/api/v1/notifications/requests/accept`, { id: ids }).then(() => {
|
|
||||||
dispatch(acceptNotificationRequestsSuccess(ids));
|
|
||||||
dispatch(decreasePendingNotificationsCount(count));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(acceptNotificationRequestFail(ids, err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const acceptNotificationRequestsRequest = ids => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_ACCEPT_REQUEST,
|
|
||||||
ids,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const acceptNotificationRequestsSuccess = ids => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_ACCEPT_SUCCESS,
|
|
||||||
ids,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const acceptNotificationRequestsFail = (ids, error) => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_ACCEPT_FAIL,
|
|
||||||
ids,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dismissNotificationRequests = (ids) => (dispatch, getState) => {
|
|
||||||
const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0);
|
|
||||||
dispatch(acceptNotificationRequestsRequest(ids));
|
|
||||||
|
|
||||||
api().post(`/api/v1/notifications/requests/dismiss`, { id: ids }).then(() => {
|
|
||||||
dispatch(dismissNotificationRequestsSuccess(ids));
|
|
||||||
dispatch(decreasePendingNotificationsCount(count));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(dismissNotificationRequestFail(ids, err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dismissNotificationRequestsRequest = ids => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_DISMISS_REQUEST,
|
|
||||||
ids,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dismissNotificationRequestsSuccess = ids => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_DISMISS_SUCCESS,
|
|
||||||
ids,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dismissNotificationRequestsFail = (ids, error) => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_DISMISS_FAIL,
|
|
||||||
ids,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationsForRequest = accountId => (dispatch, getState) => {
|
|
||||||
const current = getState().getIn(['notificationRequests', 'current']);
|
|
||||||
const params = { account_id: accountId };
|
|
||||||
|
|
||||||
if (current.getIn(['item', 'account']) === accountId) {
|
|
||||||
if (current.getIn(['notifications', 'isLoading'])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.getIn(['notifications', 'items'])?.size > 0) {
|
|
||||||
params.since_id = current.getIn(['notifications', 'items', 0, 'id']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(fetchNotificationsForRequestRequest());
|
|
||||||
|
|
||||||
api().get('/api/v1/notifications', { params }).then(response => {
|
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
|
||||||
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
|
|
||||||
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
|
|
||||||
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
|
|
||||||
|
|
||||||
dispatch(fetchNotificationsForRequestSuccess(response.data, next?.uri));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(fetchNotificationsForRequestFail(err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchNotificationsForRequestRequest = () => ({
|
|
||||||
type: NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationsForRequestSuccess = (notifications, next) => ({
|
|
||||||
type: NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS,
|
|
||||||
notifications,
|
|
||||||
next,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationsForRequestFail = (error) => ({
|
|
||||||
type: NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandNotificationsForRequest = () => (dispatch, getState) => {
|
|
||||||
const url = getState().getIn(['notificationRequests', 'current', 'notifications', 'next']);
|
|
||||||
|
|
||||||
if (!url || getState().getIn(['notificationRequests', 'current', 'notifications', 'isLoading'])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(expandNotificationsForRequestRequest());
|
|
||||||
|
|
||||||
api().get(url).then(response => {
|
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
|
||||||
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
|
|
||||||
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
|
|
||||||
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
|
|
||||||
|
|
||||||
dispatch(expandNotificationsForRequestSuccess(response.data, next?.uri));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(expandNotificationsForRequestFail(err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const expandNotificationsForRequestRequest = () => ({
|
|
||||||
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandNotificationsForRequestSuccess = (notifications, next) => ({
|
|
||||||
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS,
|
|
||||||
notifications,
|
|
||||||
next,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandNotificationsForRequestFail = (error) => ({
|
|
||||||
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
|
@ -1,14 +1,10 @@
|
||||||
import { selectUseGroupedNotifications } from 'flavours/glitch/selectors/settings';
|
|
||||||
import { createAppAsyncThunk } from 'flavours/glitch/store';
|
import { createAppAsyncThunk } from 'flavours/glitch/store';
|
||||||
|
|
||||||
import { fetchNotifications } from './notification_groups';
|
import { fetchNotifications } from './notification_groups';
|
||||||
import { expandNotifications } from './notifications';
|
|
||||||
|
|
||||||
export const initializeNotifications = createAppAsyncThunk(
|
export const initializeNotifications = createAppAsyncThunk(
|
||||||
'notifications/initialize',
|
'notifications/initialize',
|
||||||
(_, { dispatch, getState }) => {
|
(_, { dispatch }) => {
|
||||||
if (selectUseGroupedNotifications(getState()))
|
|
||||||
void dispatch(fetchNotifications());
|
void dispatch(fetchNotifications());
|
||||||
else void dispatch(expandNotifications({}));
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -49,11 +49,13 @@ export function fetchStatusRequest(id, skipLoading) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchStatus(id, forceFetch = false) {
|
export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
|
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
|
||||||
|
|
||||||
|
if (alsoFetchContext) {
|
||||||
dispatch(fetchContext(id));
|
dispatch(fetchContext(id));
|
||||||
|
}
|
||||||
|
|
||||||
if (skipLoading) {
|
if (skipLoading) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { selectUseGroupedNotifications } from 'flavours/glitch/selectors/settings';
|
|
||||||
|
|
||||||
import { getLocale } from '../locales';
|
import { getLocale } from '../locales';
|
||||||
import { connectStream } from '../stream';
|
import { connectStream } from '../stream';
|
||||||
|
|
||||||
|
@ -105,18 +103,14 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||||
const notificationJSON = JSON.parse(data.payload);
|
const notificationJSON = JSON.parse(data.payload);
|
||||||
dispatch(updateNotifications(notificationJSON, messages, locale));
|
dispatch(updateNotifications(notificationJSON, messages, locale));
|
||||||
// TODO: remove this once the groups feature replaces the previous one
|
// TODO: remove this once the groups feature replaces the previous one
|
||||||
if(selectUseGroupedNotifications(getState())) {
|
|
||||||
dispatch(processNewNotificationForGroups(notificationJSON));
|
dispatch(processNewNotificationForGroups(notificationJSON));
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'notifications_merged':
|
case 'notifications_merged':
|
||||||
const state = getState();
|
const state = getState();
|
||||||
if (state.notifications.top || !state.notifications.mounted)
|
if (state.notifications.top || !state.notifications.mounted)
|
||||||
dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
|
dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
|
||||||
if (selectUseGroupedNotifications(state)) {
|
|
||||||
dispatch(refreshStaleNotificationGroups());
|
dispatch(refreshStaleNotificationGroups());
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'conversation':
|
case 'conversation':
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
|
@ -141,22 +135,16 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Function} dispatch
|
* @param {Function} dispatch
|
||||||
* @param {Function} getState
|
|
||||||
*/
|
*/
|
||||||
async function refreshHomeTimelineAndNotification(dispatch, getState) {
|
async function refreshHomeTimelineAndNotification(dispatch) {
|
||||||
await dispatch(expandHomeTimeline({ maxId: undefined }));
|
await dispatch(expandHomeTimeline({ maxId: undefined }));
|
||||||
|
|
||||||
// TODO: remove this once the groups feature replaces the previous one
|
|
||||||
if(selectUseGroupedNotifications(getState())) {
|
|
||||||
// TODO: polling for merged notifications
|
// TODO: polling for merged notifications
|
||||||
try {
|
try {
|
||||||
await dispatch(pollRecentGroupNotifications());
|
await dispatch(pollRecentGroupNotifications());
|
||||||
} catch (error) {
|
} catch {
|
||||||
// TODO
|
// TODO
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
await dispatch(expandNotifications({}));
|
|
||||||
}
|
|
||||||
|
|
||||||
await dispatch(fetchAnnouncements());
|
await dispatch(fetchAnnouncements());
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,43 @@
|
||||||
import api, { apiRequest, getLinks } from 'flavours/glitch/api';
|
import api, {
|
||||||
import type { ApiNotificationGroupsResultJSON } from 'flavours/glitch/api_types/notifications';
|
apiRequest,
|
||||||
|
getLinks,
|
||||||
|
apiRequestGet,
|
||||||
|
apiRequestPost,
|
||||||
|
} from 'flavours/glitch/api';
|
||||||
|
import type {
|
||||||
|
ApiNotificationGroupsResultJSON,
|
||||||
|
ApiNotificationRequestJSON,
|
||||||
|
ApiNotificationJSON,
|
||||||
|
} from 'flavours/glitch/api_types/notifications';
|
||||||
|
|
||||||
export const apiFetchNotifications = async (params?: {
|
export const apiFetchNotifications = async (
|
||||||
|
params?: {
|
||||||
|
account_id?: string;
|
||||||
|
since_id?: string;
|
||||||
|
},
|
||||||
|
url?: string,
|
||||||
|
) => {
|
||||||
|
const response = await api().request<ApiNotificationJSON[]>({
|
||||||
|
method: 'GET',
|
||||||
|
url: url ?? '/api/v1/notifications',
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: response.data,
|
||||||
|
links: getLinks(response),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiFetchNotificationGroups = async (params?: {
|
||||||
|
url?: string;
|
||||||
exclude_types?: string[];
|
exclude_types?: string[];
|
||||||
max_id?: string;
|
max_id?: string;
|
||||||
since_id?: string;
|
since_id?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const response = await api().request<ApiNotificationGroupsResultJSON>({
|
const response = await api().request<ApiNotificationGroupsResultJSON>({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/api/v2_alpha/notifications',
|
url: '/api/v2/notifications',
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -24,3 +53,43 @@ export const apiFetchNotifications = async (params?: {
|
||||||
|
|
||||||
export const apiClearNotifications = () =>
|
export const apiClearNotifications = () =>
|
||||||
apiRequest<undefined>('POST', 'v1/notifications/clear');
|
apiRequest<undefined>('POST', 'v1/notifications/clear');
|
||||||
|
|
||||||
|
export const apiFetchNotificationRequests = async (
|
||||||
|
params?: {
|
||||||
|
since_id?: string;
|
||||||
|
},
|
||||||
|
url?: string,
|
||||||
|
) => {
|
||||||
|
const response = await api().request<ApiNotificationRequestJSON[]>({
|
||||||
|
method: 'GET',
|
||||||
|
url: url ?? '/api/v1/notifications/requests',
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
requests: response.data,
|
||||||
|
links: getLinks(response),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiFetchNotificationRequest = async (id: string) => {
|
||||||
|
return apiRequestGet<ApiNotificationRequestJSON>(
|
||||||
|
`v1/notifications/requests/${id}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiAcceptNotificationRequest = async (id: string) => {
|
||||||
|
return apiRequestPost(`v1/notifications/requests/${id}/accept`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiDismissNotificationRequest = async (id: string) => {
|
||||||
|
return apiRequestPost(`v1/notifications/requests/${id}/dismiss`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiAcceptNotificationRequests = async (id: string[]) => {
|
||||||
|
return apiRequestPost('v1/notifications/requests/accept', { id });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiDismissNotificationRequests = async (id: string[]) => {
|
||||||
|
return apiRequestPost('v1/notifications/dismiss/dismiss', { id });
|
||||||
|
};
|
||||||
|
|
|
@ -151,3 +151,12 @@ export interface ApiNotificationGroupsResultJSON {
|
||||||
statuses: ApiStatusJSON[];
|
statuses: ApiStatusJSON[];
|
||||||
notification_groups: ApiNotificationGroupJSON[];
|
notification_groups: ApiNotificationGroupJSON[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiNotificationRequestJSON {
|
||||||
|
id: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
notifications_count: string;
|
||||||
|
account: ApiAccountJSON;
|
||||||
|
last_status?: ApiStatusJSON;
|
||||||
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ export function start() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Rails.start();
|
Rails.start();
|
||||||
} catch (e) {
|
} catch {
|
||||||
// If called twice
|
// If called twice
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useRef, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import { useTimeout } from 'flavours/glitch/hooks/useTimeout';
|
||||||
|
|
||||||
|
export const CopyPasteText: React.FC<{ value: string }> = ({ value }) => {
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [setAnimationTimeout] = useTimeout();
|
||||||
|
|
||||||
|
const handleInputClick = useCallback(() => {
|
||||||
|
setCopied(false);
|
||||||
|
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
inputRef.current.select();
|
||||||
|
inputRef.current.setSelectionRange(0, value.length);
|
||||||
|
}
|
||||||
|
}, [setCopied, value]);
|
||||||
|
|
||||||
|
const handleButtonClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void navigator.clipboard.writeText(value);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
setCopied(true);
|
||||||
|
setAnimationTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 700);
|
||||||
|
},
|
||||||
|
[setCopied, setAnimationTimeout, value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyUp = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key !== ' ') return;
|
||||||
|
void navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
setAnimationTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 700);
|
||||||
|
},
|
||||||
|
[setCopied, setAnimationTimeout, value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
setFocused(true);
|
||||||
|
}, [setFocused]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
setFocused(false);
|
||||||
|
}, [setFocused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames('copy-paste-text', { copied, focused })}
|
||||||
|
tabIndex={0}
|
||||||
|
role='button'
|
||||||
|
onClick={handleInputClick}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={value}
|
||||||
|
ref={inputRef}
|
||||||
|
onClick={handleInputClick}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button className='button' onClick={handleButtonClick}>
|
||||||
|
<Icon id='copy' icon={ContentCopyIcon} />{' '}
|
||||||
|
{copied ? (
|
||||||
|
<FormattedMessage id='copypaste.copied' defaultMessage='Copied' />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage
|
||||||
|
id='copypaste.copy_to_clipboard'
|
||||||
|
defaultMessage='Copy to clipboard'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -60,8 +60,8 @@ export default class ErrorBoundary extends PureComponent {
|
||||||
try {
|
try {
|
||||||
textarea.select();
|
textarea.select();
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
} catch (e) {
|
} catch {
|
||||||
|
// do nothing
|
||||||
} finally {
|
} finally {
|
||||||
document.body.removeChild(textarea);
|
document.body.removeChild(textarea);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,13 @@ export const WordmarkLogo: React.FC = () => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const IconLogo: React.FC = () => (
|
||||||
|
<svg viewBox='0 0 79 79' className='logo logo--icon' role='img'>
|
||||||
|
<title>Mastodon</title>
|
||||||
|
<use xlinkHref='#logo-symbol-icon' />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const SymbolLogo: React.FC = () => (
|
export const SymbolLogo: React.FC = () => (
|
||||||
<img src={logo} alt='Mastodon' className='logo logo--icon' />
|
<img src={logo} alt='Mastodon' className='logo logo--icon' />
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
@ -10,17 +10,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
|
|
||||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
|
||||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||||
|
|
||||||
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
|
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
|
||||||
|
|
||||||
import { IconButton } from './icon_button';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: '{number, plural, one {Hide image} other {Hide images}}' },
|
|
||||||
});
|
|
||||||
|
|
||||||
class Item extends PureComponent {
|
class Item extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
@ -220,7 +213,6 @@ class MediaGallery extends PureComponent {
|
||||||
lang: PropTypes.string,
|
lang: PropTypes.string,
|
||||||
size: PropTypes.object,
|
size: PropTypes.object,
|
||||||
onOpenMedia: PropTypes.func.isRequired,
|
onOpenMedia: PropTypes.func.isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
defaultWidth: PropTypes.number,
|
defaultWidth: PropTypes.number,
|
||||||
cacheWidth: PropTypes.func,
|
cacheWidth: PropTypes.func,
|
||||||
visible: PropTypes.bool,
|
visible: PropTypes.bool,
|
||||||
|
@ -309,7 +301,7 @@ class MediaGallery extends PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media, lang, intl, sensitive, letterbox, fullwidth, defaultWidth, autoplay } = this.props;
|
const { media, lang, sensitive, letterbox, fullwidth, defaultWidth, autoplay } = this.props;
|
||||||
const { visible } = this.state;
|
const { visible } = this.state;
|
||||||
const size = media.size;
|
const size = media.size;
|
||||||
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
|
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
|
||||||
|
@ -320,7 +312,7 @@ class MediaGallery extends PureComponent {
|
||||||
|
|
||||||
const style = {};
|
const style = {};
|
||||||
|
|
||||||
const computedClass = classNames('media-gallery', { 'full-width': fullwidth });
|
const computedClass = classNames('media-gallery', `media-gallery--layout-${size}`, { 'full-width': fullwidth });
|
||||||
|
|
||||||
if (this.isStandaloneEligible()) { // TODO: cropImages setting
|
if (this.isStandaloneEligible()) { // TODO: cropImages setting
|
||||||
style.aspectRatio = `${this.props.media.getIn([0, 'meta', 'small', 'aspect'])}`;
|
style.aspectRatio = `${this.props.media.getIn([0, 'meta', 'small', 'aspect'])}`;
|
||||||
|
@ -343,9 +335,7 @@ class MediaGallery extends PureComponent {
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
} else if (visible) {
|
} else if (!visible) {
|
||||||
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' iconComponent={VisibilityOffIcon} overlay onClick={this.handleOpen} ariaHidden />;
|
|
||||||
} else {
|
|
||||||
spoilerButton = (
|
spoilerButton = (
|
||||||
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
|
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
|
||||||
<span className='spoiler-button__overlay__label'>
|
<span className='spoiler-button__overlay__label'>
|
||||||
|
@ -358,15 +348,23 @@ class MediaGallery extends PureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={computedClass} style={style} ref={this.handleRef}>
|
<div className={computedClass} style={style} ref={this.handleRef}>
|
||||||
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
|
{(!visible || uncached) && (
|
||||||
|
<div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
|
||||||
{spoilerButton}
|
{spoilerButton}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
|
{(visible && !uncached) && (
|
||||||
|
<div className='media-gallery__actions'>
|
||||||
|
<button className='media-gallery__actions__pill' onClick={this.handleOpen}><FormattedMessage id='media_gallery.hide' defaultMessage='Hide' /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default injectIntl(MediaGallery);
|
export default MediaGallery;
|
||||||
|
|
|
@ -2,14 +2,12 @@ import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { IconLogo } from 'flavours/glitch/components/logo';
|
||||||
import { AuthorLink } from 'flavours/glitch/features/explore/components/author_link';
|
import { AuthorLink } from 'flavours/glitch/features/explore/components/author_link';
|
||||||
|
|
||||||
export const MoreFromAuthor = ({ accountId }) => (
|
export const MoreFromAuthor = ({ accountId }) => (
|
||||||
<div className='more-from-author'>
|
<div className='more-from-author'>
|
||||||
<svg viewBox='0 0 79 79' className='logo logo--icon' role='img'>
|
<IconLogo />
|
||||||
<use xlinkHref='#logo-symbol-icon' />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <AuthorLink accountId={accountId} /> }} />
|
<FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <AuthorLink accountId={accountId} /> }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -59,7 +59,7 @@ const messages = defineMessages({
|
||||||
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
|
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
|
||||||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
||||||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
||||||
embed: { id: 'status.embed', defaultMessage: 'Embed' },
|
embed: { id: 'status.embed', defaultMessage: 'Get embed code' },
|
||||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
||||||
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
|
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
|
||||||
|
|
|
@ -163,11 +163,13 @@ export default class StatusPrepend extends PureComponent {
|
||||||
|
|
||||||
return !type ? null : (
|
return !type ? null : (
|
||||||
<aside className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend' : 'notification__message'}>
|
<aside className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend' : 'notification__message'}>
|
||||||
|
<div className='status__prepend__icon'>
|
||||||
<Icon
|
<Icon
|
||||||
className={`status__prepend-icon ${type === 'favourite' ? 'star-icon' : ''}`}
|
className={type === 'favourite' ? 'star-icon' : null}
|
||||||
id={iconId}
|
id={iconId}
|
||||||
icon={iconComponent}
|
icon={iconComponent}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<Message />
|
<Message />
|
||||||
{children}
|
{children}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
@ -21,9 +21,9 @@ export default class StatusReactions extends ImmutablePureComponent {
|
||||||
statusId: PropTypes.string.isRequired,
|
statusId: PropTypes.string.isRequired,
|
||||||
reactions: ImmutablePropTypes.list.isRequired,
|
reactions: ImmutablePropTypes.list.isRequired,
|
||||||
numVisible: PropTypes.number,
|
numVisible: PropTypes.number,
|
||||||
addReaction: PropTypes.func.isRequired,
|
addReaction: PropTypes.func,
|
||||||
canReact: PropTypes.bool.isRequired,
|
canReact: PropTypes.bool.isRequired,
|
||||||
removeReaction: PropTypes.func.isRequired,
|
removeReaction: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
willEnter() {
|
willEnter() {
|
||||||
|
@ -78,8 +78,8 @@ class Reaction extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
statusId: PropTypes.string,
|
statusId: PropTypes.string,
|
||||||
reaction: ImmutablePropTypes.map.isRequired,
|
reaction: ImmutablePropTypes.map.isRequired,
|
||||||
addReaction: PropTypes.func.isRequired,
|
addReaction: PropTypes.func,
|
||||||
removeReaction: PropTypes.func.isRequired,
|
removeReaction: PropTypes.func,
|
||||||
canReact: PropTypes.bool.isRequired,
|
canReact: PropTypes.bool.isRequired,
|
||||||
style: PropTypes.object,
|
style: PropTypes.object,
|
||||||
};
|
};
|
||||||
|
@ -91,9 +91,9 @@ class Reaction extends ImmutablePureComponent {
|
||||||
handleClick = () => {
|
handleClick = () => {
|
||||||
const { reaction, statusId, addReaction, removeReaction } = this.props;
|
const { reaction, statusId, addReaction, removeReaction } = this.props;
|
||||||
|
|
||||||
if (reaction.get('me')) {
|
if (reaction.get('me') && removeReaction) {
|
||||||
removeReaction(statusId, reaction.get('name'));
|
removeReaction(statusId, reaction.get('name'));
|
||||||
} else {
|
} else if (addReaction) {
|
||||||
addReaction(statusId, reaction.get('name'));
|
addReaction(statusId, reaction.get('name'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import { DisplayedName } from 'flavours/glitch/features/notifications_v2/components/displayed_name';
|
||||||
|
import { useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
export const StatusThreadLabel: React.FC<{
|
||||||
|
accountId: string;
|
||||||
|
inReplyToAccountId: string;
|
||||||
|
}> = ({ accountId, inReplyToAccountId }) => {
|
||||||
|
const inReplyToAccount = useAppSelector((state) =>
|
||||||
|
state.accounts.get(inReplyToAccountId),
|
||||||
|
);
|
||||||
|
|
||||||
|
let label;
|
||||||
|
|
||||||
|
if (accountId === inReplyToAccountId) {
|
||||||
|
label = (
|
||||||
|
<FormattedMessage
|
||||||
|
id='status.continued_thread'
|
||||||
|
defaultMessage='Continued thread'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (inReplyToAccount) {
|
||||||
|
label = (
|
||||||
|
<FormattedMessage
|
||||||
|
id='status.replied_to'
|
||||||
|
defaultMessage='Replied to {name}'
|
||||||
|
values={{ name: <DisplayedName accountIds={[inReplyToAccountId]} /> }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
label = (
|
||||||
|
<FormattedMessage
|
||||||
|
id='status.replied_in_thread'
|
||||||
|
defaultMessage='Replied in thread'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='status__prepend'>
|
||||||
|
<div className='status__prepend__icon'>
|
||||||
|
<Icon id='reply' icon={ReplyIcon} />
|
||||||
|
</div>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -36,8 +36,6 @@ import Status from 'flavours/glitch/components/status';
|
||||||
import { deleteModal } from 'flavours/glitch/initial_state';
|
import { deleteModal } from 'flavours/glitch/initial_state';
|
||||||
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
|
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
|
||||||
|
|
||||||
import { showAlertForError } from '../actions/alerts';
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
const getPictureInPicture = makeGetPictureInPicture();
|
const getPictureInPicture = makeGetPictureInPicture();
|
||||||
|
@ -121,10 +119,7 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
||||||
onEmbed (status) {
|
onEmbed (status) {
|
||||||
dispatch(openModal({
|
dispatch(openModal({
|
||||||
modalType: 'EMBED',
|
modalType: 'EMBED',
|
||||||
modalProps: {
|
modalProps: { id: status.get('id') },
|
||||||
id: status.get('id'),
|
|
||||||
onError: error => dispatch(showAlertForError(error)),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
74
app/javascript/flavours/glitch/entrypoints/embed.tsx
Normal file
74
app/javascript/flavours/glitch/entrypoints/embed.tsx
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import '@/entrypoints/public-path';
|
||||||
|
|
||||||
|
import { start } from 'flavours/glitch/common';
|
||||||
|
import { Status } from 'flavours/glitch/features/standalone/status';
|
||||||
|
import { afterInitialRender } from 'flavours/glitch/hooks/useRenderSignal';
|
||||||
|
import { loadPolyfills } from 'flavours/glitch/polyfills';
|
||||||
|
import ready from 'flavours/glitch/ready';
|
||||||
|
|
||||||
|
start();
|
||||||
|
|
||||||
|
function loaded() {
|
||||||
|
const mountNode = document.getElementById('mastodon-status');
|
||||||
|
|
||||||
|
if (mountNode) {
|
||||||
|
const attr = mountNode.getAttribute('data-props');
|
||||||
|
|
||||||
|
if (!attr) return;
|
||||||
|
|
||||||
|
const props = JSON.parse(attr) as { id: string; locale: string };
|
||||||
|
const root = createRoot(mountNode);
|
||||||
|
|
||||||
|
root.render(<Status {...props} />);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
ready(loaded).catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPolyfills()
|
||||||
|
.then(main)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SetHeightMessage {
|
||||||
|
type: 'setHeight';
|
||||||
|
id: string;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSetHeightMessage(data: unknown): data is SetHeightMessage {
|
||||||
|
if (
|
||||||
|
data &&
|
||||||
|
typeof data === 'object' &&
|
||||||
|
'type' in data &&
|
||||||
|
data.type === 'setHeight'
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
else return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', (e) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases
|
||||||
|
if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return;
|
||||||
|
|
||||||
|
const data = e.data;
|
||||||
|
|
||||||
|
// We use a timeout to allow for the React page to render before calculating the height
|
||||||
|
afterInitialRender(() => {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
type: 'setHeight',
|
||||||
|
id: data.id,
|
||||||
|
height: document.getElementsByTagName('html')[0]?.scrollHeight,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -37,43 +37,6 @@ const messages = defineMessages({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SetHeightMessage {
|
|
||||||
type: 'setHeight';
|
|
||||||
id: string;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSetHeightMessage(data: unknown): data is SetHeightMessage {
|
|
||||||
if (
|
|
||||||
data &&
|
|
||||||
typeof data === 'object' &&
|
|
||||||
'type' in data &&
|
|
||||||
data.type === 'setHeight'
|
|
||||||
)
|
|
||||||
return true;
|
|
||||||
else return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('message', (e) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases
|
|
||||||
if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return;
|
|
||||||
|
|
||||||
const data = e.data;
|
|
||||||
|
|
||||||
ready(() => {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: 'setHeight',
|
|
||||||
id: data.id,
|
|
||||||
height: document.getElementsByTagName('html')[0]?.scrollHeight,
|
|
||||||
},
|
|
||||||
'*',
|
|
||||||
);
|
|
||||||
}).catch((e: unknown) => {
|
|
||||||
console.error('Error in setHeightMessage postMessage', e);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function loaded() {
|
function loaded() {
|
||||||
const { messages: localeData } = getLocale();
|
const { messages: localeData } = getLocale();
|
||||||
|
|
||||||
|
|
|
@ -131,7 +131,7 @@ class LoginForm extends React.PureComponent {
|
||||||
try {
|
try {
|
||||||
new URL(url);
|
new URL(url);
|
||||||
return true;
|
return true;
|
||||||
} catch(_) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,7 +6,6 @@ import { FormattedMessage } from 'react-intl';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
|
||||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||||
import { forceGroupedNotifications } from 'flavours/glitch/initial_state';
|
|
||||||
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'flavours/glitch/permissions';
|
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'flavours/glitch/permissions';
|
||||||
|
|
||||||
import ClearColumnButton from './clear_column_button';
|
import ClearColumnButton from './clear_column_button';
|
||||||
|
@ -36,7 +35,6 @@ class ColumnSettings extends PureComponent {
|
||||||
const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission } = this.props;
|
const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission } = this.props;
|
||||||
|
|
||||||
const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />;
|
const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />;
|
||||||
const groupingShowStr = <FormattedMessage id='notifications.column_settings.beta.grouping' defaultMessage='Group notifications' />;
|
|
||||||
const filterBarShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show_bar' defaultMessage='Show filter bar' />;
|
const filterBarShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show_bar' defaultMessage='Show filter bar' />;
|
||||||
const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
|
const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
|
||||||
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
|
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
|
||||||
|
@ -79,18 +77,6 @@ class ColumnSettings extends PureComponent {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{!forceGroupedNotifications && (
|
|
||||||
<section role='group' aria-labelledby='notifications-beta'>
|
|
||||||
<h3 id='notifications-beta'>
|
|
||||||
<FormattedMessage id='notifications.column_settings.beta.category' defaultMessage='Experimental features' />
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className='column-settings__row'>
|
|
||||||
<SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['groupingBeta']} onChange={onChange} label={groupingShowStr} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section role='group' aria-labelledby='notifications-unread-markers'>
|
<section role='group' aria-labelledby='notifications-unread-markers'>
|
||||||
<h3 id='notifications-unread-markers'>
|
<h3 id='notifications-unread-markers'>
|
||||||
<FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />
|
<FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />
|
||||||
|
|
|
@ -12,7 +12,7 @@ import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import { initBlockModal } from 'flavours/glitch/actions/blocks';
|
import { initBlockModal } from 'flavours/glitch/actions/blocks';
|
||||||
import { initMuteModal } from 'flavours/glitch/actions/mutes';
|
import { initMuteModal } from 'flavours/glitch/actions/mutes';
|
||||||
import { acceptNotificationRequest, dismissNotificationRequest } from 'flavours/glitch/actions/notifications';
|
import { acceptNotificationRequest, dismissNotificationRequest } from 'flavours/glitch/actions/notification_requests';
|
||||||
import { initReport } from 'flavours/glitch/actions/reports';
|
import { initReport } from 'flavours/glitch/actions/reports';
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
import { CheckBox } from 'flavours/glitch/components/check_box';
|
import { CheckBox } from 'flavours/glitch/components/check_box';
|
||||||
|
@ -40,11 +40,11 @@ export const NotificationRequest = ({ id, accountId, notificationsCount, checked
|
||||||
const { push: historyPush } = useHistory();
|
const { push: historyPush } = useHistory();
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
dispatch(dismissNotificationRequest(id));
|
dispatch(dismissNotificationRequest({ id }));
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
||||||
const handleAccept = useCallback(() => {
|
const handleAccept = useCallback(() => {
|
||||||
dispatch(acceptNotificationRequest(id));
|
dispatch(acceptNotificationRequest({ id }));
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
||||||
const handleMute = useCallback(() => {
|
const handleMute = useCallback(() => {
|
||||||
|
|
|
@ -10,7 +10,13 @@ import { useSelector, useDispatch } from 'react-redux';
|
||||||
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
|
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
|
||||||
import DoneIcon from '@/material-icons/400-24px/done.svg?react';
|
import DoneIcon from '@/material-icons/400-24px/done.svg?react';
|
||||||
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
|
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
|
||||||
import { fetchNotificationRequest, fetchNotificationsForRequest, expandNotificationsForRequest, acceptNotificationRequest, dismissNotificationRequest } from 'flavours/glitch/actions/notifications';
|
import {
|
||||||
|
fetchNotificationRequest,
|
||||||
|
fetchNotificationsForRequest,
|
||||||
|
expandNotificationsForRequest,
|
||||||
|
acceptNotificationRequest,
|
||||||
|
dismissNotificationRequest,
|
||||||
|
} from 'flavours/glitch/actions/notification_requests';
|
||||||
import Column from 'flavours/glitch/components/column';
|
import Column from 'flavours/glitch/components/column';
|
||||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
|
@ -44,28 +50,28 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
||||||
const columnRef = useRef();
|
const columnRef = useRef();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const notificationRequest = useSelector(state => state.getIn(['notificationRequests', 'current', 'item', 'id']) === id ? state.getIn(['notificationRequests', 'current', 'item']) : null);
|
const notificationRequest = useSelector(state => state.notificationRequests.current.item?.id === id ? state.notificationRequests.current.item : null);
|
||||||
const accountId = notificationRequest?.get('account');
|
const accountId = notificationRequest?.account_id;
|
||||||
const account = useSelector(state => state.getIn(['accounts', accountId]));
|
const account = useSelector(state => state.getIn(['accounts', accountId]));
|
||||||
const notifications = useSelector(state => state.getIn(['notificationRequests', 'current', 'notifications', 'items']));
|
const notifications = useSelector(state => state.notificationRequests.current.notifications.items);
|
||||||
const isLoading = useSelector(state => state.getIn(['notificationRequests', 'current', 'notifications', 'isLoading']));
|
const isLoading = useSelector(state => state.notificationRequests.current.notifications.isLoading);
|
||||||
const hasMore = useSelector(state => !!state.getIn(['notificationRequests', 'current', 'notifications', 'next']));
|
const hasMore = useSelector(state => !!state.notificationRequests.current.notifications.next);
|
||||||
const removed = useSelector(state => state.getIn(['notificationRequests', 'current', 'removed']));
|
const removed = useSelector(state => state.notificationRequests.current.removed);
|
||||||
|
|
||||||
const handleHeaderClick = useCallback(() => {
|
const handleHeaderClick = useCallback(() => {
|
||||||
columnRef.current?.scrollTop();
|
columnRef.current?.scrollTop();
|
||||||
}, [columnRef]);
|
}, [columnRef]);
|
||||||
|
|
||||||
const handleLoadMore = useCallback(() => {
|
const handleLoadMore = useCallback(() => {
|
||||||
dispatch(expandNotificationsForRequest());
|
dispatch(expandNotificationsForRequest({ accountId }));
|
||||||
}, [dispatch]);
|
}, [dispatch, accountId]);
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
dispatch(dismissNotificationRequest(id));
|
dispatch(dismissNotificationRequest({ id }));
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
||||||
const handleAccept = useCallback(() => {
|
const handleAccept = useCallback(() => {
|
||||||
dispatch(acceptNotificationRequest(id));
|
dispatch(acceptNotificationRequest({ id }));
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
||||||
const handleMoveUp = useCallback(id => {
|
const handleMoveUp = useCallback(id => {
|
||||||
|
@ -79,12 +85,12 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
||||||
}, [columnRef, notifications]);
|
}, [columnRef, notifications]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchNotificationRequest(id));
|
dispatch(fetchNotificationRequest({ id }));
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
dispatch(fetchNotificationsForRequest(accountId));
|
dispatch(fetchNotificationsForRequest({ accountId }));
|
||||||
}
|
}
|
||||||
}, [dispatch, accountId]);
|
}, [dispatch, accountId]);
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,12 @@ import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?rea
|
||||||
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
|
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
import { fetchNotificationRequests, expandNotificationRequests, acceptNotificationRequests, dismissNotificationRequests } from 'flavours/glitch/actions/notifications';
|
import {
|
||||||
|
fetchNotificationRequests,
|
||||||
|
expandNotificationRequests,
|
||||||
|
acceptNotificationRequests,
|
||||||
|
dismissNotificationRequests,
|
||||||
|
} from 'flavours/glitch/actions/notification_requests';
|
||||||
import { changeSetting } from 'flavours/glitch/actions/settings';
|
import { changeSetting } from 'flavours/glitch/actions/settings';
|
||||||
import { CheckBox } from 'flavours/glitch/components/check_box';
|
import { CheckBox } from 'flavours/glitch/components/check_box';
|
||||||
import Column from 'flavours/glitch/components/column';
|
import Column from 'flavours/glitch/components/column';
|
||||||
|
@ -84,7 +89,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
|
||||||
message: intl.formatMessage(messages.confirmAcceptMultipleMessage, { count: selectedItems.length }),
|
message: intl.formatMessage(messages.confirmAcceptMultipleMessage, { count: selectedItems.length }),
|
||||||
confirm: intl.formatMessage(messages.confirmAcceptMultipleButton, { count: selectedItems.length}),
|
confirm: intl.formatMessage(messages.confirmAcceptMultipleButton, { count: selectedItems.length}),
|
||||||
onConfirm: () =>
|
onConfirm: () =>
|
||||||
dispatch(acceptNotificationRequests(selectedItems)),
|
dispatch(acceptNotificationRequests({ ids: selectedItems })),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}, [dispatch, intl, selectedItems]);
|
}, [dispatch, intl, selectedItems]);
|
||||||
|
@ -97,7 +102,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
|
||||||
message: intl.formatMessage(messages.confirmDismissMultipleMessage, { count: selectedItems.length }),
|
message: intl.formatMessage(messages.confirmDismissMultipleMessage, { count: selectedItems.length }),
|
||||||
confirm: intl.formatMessage(messages.confirmDismissMultipleButton, { count: selectedItems.length}),
|
confirm: intl.formatMessage(messages.confirmDismissMultipleButton, { count: selectedItems.length}),
|
||||||
onConfirm: () =>
|
onConfirm: () =>
|
||||||
dispatch(dismissNotificationRequests(selectedItems)),
|
dispatch(dismissNotificationRequests({ ids: selectedItems })),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}, [dispatch, intl, selectedItems]);
|
}, [dispatch, intl, selectedItems]);
|
||||||
|
@ -161,9 +166,9 @@ export const NotificationRequests = ({ multiColumn }) => {
|
||||||
const columnRef = useRef();
|
const columnRef = useRef();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const isLoading = useSelector(state => state.getIn(['notificationRequests', 'isLoading']));
|
const isLoading = useSelector(state => state.notificationRequests.isLoading);
|
||||||
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
|
const notificationRequests = useSelector(state => state.notificationRequests.items);
|
||||||
const hasMore = useSelector(state => !!state.getIn(['notificationRequests', 'next']));
|
const hasMore = useSelector(state => !!state.notificationRequests.next);
|
||||||
|
|
||||||
const [selectionMode, setSelectionMode] = useState(false);
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
const [checkedRequestIds, setCheckedRequestIds] = useState([]);
|
const [checkedRequestIds, setCheckedRequestIds] = useState([]);
|
||||||
|
@ -182,7 +187,7 @@ export const NotificationRequests = ({ multiColumn }) => {
|
||||||
else
|
else
|
||||||
ids.push(id);
|
ids.push(id);
|
||||||
|
|
||||||
setSelectAllChecked(ids.length === notificationRequests.size);
|
setSelectAllChecked(ids.length === notificationRequests.length);
|
||||||
|
|
||||||
return [...ids];
|
return [...ids];
|
||||||
});
|
});
|
||||||
|
@ -193,7 +198,7 @@ export const NotificationRequests = ({ multiColumn }) => {
|
||||||
if(checked)
|
if(checked)
|
||||||
setCheckedRequestIds([]);
|
setCheckedRequestIds([]);
|
||||||
else
|
else
|
||||||
setCheckedRequestIds(notificationRequests.map(request => request.get('id')).toArray());
|
setCheckedRequestIds(notificationRequests.map(request => request.id));
|
||||||
|
|
||||||
return !checked;
|
return !checked;
|
||||||
});
|
});
|
||||||
|
@ -217,7 +222,7 @@ export const NotificationRequests = ({ multiColumn }) => {
|
||||||
multiColumn={multiColumn}
|
multiColumn={multiColumn}
|
||||||
showBackButton
|
showBackButton
|
||||||
appendContent={
|
appendContent={
|
||||||
notificationRequests.size > 0 && (
|
notificationRequests.length > 0 && (
|
||||||
<SelectRow selectionMode={selectionMode} setSelectionMode={setSelectionMode} selectAllChecked={selectAllChecked} toggleSelectAll={toggleSelectAll} selectedItems={checkedRequestIds} />
|
<SelectRow selectionMode={selectionMode} setSelectionMode={setSelectionMode} selectAllChecked={selectAllChecked} toggleSelectAll={toggleSelectAll} selectedItems={checkedRequestIds} />
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -236,12 +241,12 @@ export const NotificationRequests = ({ multiColumn }) => {
|
||||||
>
|
>
|
||||||
{notificationRequests.map(request => (
|
{notificationRequests.map(request => (
|
||||||
<NotificationRequest
|
<NotificationRequest
|
||||||
key={request.get('id')}
|
key={request.id}
|
||||||
id={request.get('id')}
|
id={request.id}
|
||||||
accountId={request.get('account')}
|
accountId={request.account_id}
|
||||||
notificationsCount={request.get('notifications_count')}
|
notificationsCount={request.notifications_count}
|
||||||
showCheckbox={selectionMode}
|
showCheckbox={selectionMode}
|
||||||
checked={checkedRequestIds.includes(request.get('id'))}
|
checked={checkedRequestIds.includes(request.id)}
|
||||||
toggleCheck={handleCheck}
|
toggleCheck={handleCheck}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { isEqual } from 'lodash';
|
||||||
|
|
||||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||||
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
|
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
|
||||||
import { me } from 'flavours/glitch/initial_state';
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
|
@ -47,7 +49,7 @@ export const NotificationMention: React.FC<{
|
||||||
status.get('visibility') === 'direct',
|
status.get('visibility') === 'direct',
|
||||||
status.get('in_reply_to_account_id') === me,
|
status.get('in_reply_to_account_id') === me,
|
||||||
] as const;
|
] as const;
|
||||||
});
|
}, isEqual);
|
||||||
|
|
||||||
let labelRenderer = mentionLabelRenderer;
|
let labelRenderer = mentionLabelRenderer;
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
|
|
||||||
|
import { isEqual } from 'lodash';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
|
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
|
||||||
|
@ -62,7 +63,7 @@ export const Notifications: React.FC<{
|
||||||
multiColumn?: boolean;
|
multiColumn?: boolean;
|
||||||
}> = ({ columnId, multiColumn }) => {
|
}> = ({ columnId, multiColumn }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const notifications = useAppSelector(selectNotificationGroups);
|
const notifications = useAppSelector(selectNotificationGroups, isEqual);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const isLoading = useAppSelector((s) => s.notificationGroups.isLoading);
|
const isLoading = useAppSelector((s) => s.notificationGroups.isLoading);
|
||||||
const hasMore = notifications.at(-1)?.type === 'gap';
|
const hasMore = notifications.at(-1)?.type === 'gap';
|
||||||
|
|
|
@ -1,13 +1,8 @@
|
||||||
import Notifications from 'flavours/glitch/features/notifications';
|
|
||||||
import Notifications_v2 from 'flavours/glitch/features/notifications_v2';
|
import Notifications_v2 from 'flavours/glitch/features/notifications_v2';
|
||||||
import { selectUseGroupedNotifications } from 'flavours/glitch/selectors/settings';
|
|
||||||
import { useAppSelector } from 'flavours/glitch/store';
|
|
||||||
|
|
||||||
export const NotificationsWrapper = (props) => {
|
export const NotificationsWrapper = (props) => {
|
||||||
const optedInGroupedNotifications = useAppSelector(selectUseGroupedNotifications);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
optedInGroupedNotifications ? <Notifications_v2 {...props} /> : <Notifications {...props} />
|
<Notifications_v2 {...props} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -10,8 +10,8 @@ import { Link } from 'react-router-dom';
|
||||||
import SwipeableViews from 'react-swipeable-views';
|
import SwipeableViews from 'react-swipeable-views';
|
||||||
|
|
||||||
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
|
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
|
||||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
|
||||||
import { ColumnBackButton } from 'flavours/glitch/components/column_back_button';
|
import { ColumnBackButton } from 'flavours/glitch/components/column_back_button';
|
||||||
|
import { CopyPasteText } from 'flavours/glitch/components/copy_paste_text';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { me, domain } from 'flavours/glitch/initial_state';
|
import { me, domain } from 'flavours/glitch/initial_state';
|
||||||
import { useAppSelector } from 'flavours/glitch/store';
|
import { useAppSelector } from 'flavours/glitch/store';
|
||||||
|
@ -20,67 +20,6 @@ const messages = defineMessages({
|
||||||
shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' },
|
shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' },
|
||||||
});
|
});
|
||||||
|
|
||||||
class CopyPasteText extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
value: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
copied: false,
|
|
||||||
focused: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.input = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleInputClick = () => {
|
|
||||||
this.setState({ copied: false });
|
|
||||||
this.input.focus();
|
|
||||||
this.input.select();
|
|
||||||
this.input.setSelectionRange(0, this.props.value.length);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleButtonClick = e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const { value } = this.props;
|
|
||||||
navigator.clipboard.writeText(value);
|
|
||||||
this.input.blur();
|
|
||||||
this.setState({ copied: true });
|
|
||||||
this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleFocus = () => {
|
|
||||||
this.setState({ focused: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleBlur = () => {
|
|
||||||
this.setState({ focused: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
if (this.timeout) clearTimeout(this.timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { value } = this.props;
|
|
||||||
const { copied, focused } = this.state;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames('copy-paste-text', { copied, focused })} tabIndex='0' role='button' onClick={this.handleInputClick}>
|
|
||||||
<textarea readOnly value={value} ref={this.setRef} onClick={this.handleInputClick} onFocus={this.handleFocus} onBlur={this.handleBlur} />
|
|
||||||
|
|
||||||
<button className='button' onClick={this.handleButtonClick}>
|
|
||||||
<Icon id='copy' icon={ContentCopyIcon} /> {copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : <FormattedMessage id='copypaste.copy_to_clipboard' defaultMessage='Copy to clipboard' />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class TipCarousel extends PureComponent {
|
class TipCarousel extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
|
|
@ -0,0 +1,94 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-return,
|
||||||
|
@typescript-eslint/no-explicit-any,
|
||||||
|
@typescript-eslint/no-unsafe-assignment */
|
||||||
|
|
||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchStatus,
|
||||||
|
toggleStatusSpoilers,
|
||||||
|
} from 'flavours/glitch/actions/statuses';
|
||||||
|
import { hydrateStore } from 'flavours/glitch/actions/store';
|
||||||
|
import { Router } from 'flavours/glitch/components/router';
|
||||||
|
import { DetailedStatus } from 'flavours/glitch/features/status/components/detailed_status';
|
||||||
|
import { useRenderSignal } from 'flavours/glitch/hooks/useRenderSignal';
|
||||||
|
import initialState from 'flavours/glitch/initial_state';
|
||||||
|
import { IntlProvider } from 'flavours/glitch/locales';
|
||||||
|
import {
|
||||||
|
makeGetStatus,
|
||||||
|
makeGetPictureInPicture,
|
||||||
|
} from 'flavours/glitch/selectors';
|
||||||
|
import { store, useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any;
|
||||||
|
const getPictureInPicture = makeGetPictureInPicture() as unknown as (
|
||||||
|
arg0: any,
|
||||||
|
arg1: any,
|
||||||
|
) => any;
|
||||||
|
|
||||||
|
const Embed: React.FC<{ id: string }> = ({ id }) => {
|
||||||
|
const status = useAppSelector((state) => getStatus(state, { id }));
|
||||||
|
const pictureInPicture = useAppSelector((state) =>
|
||||||
|
getPictureInPicture(state, { id }),
|
||||||
|
);
|
||||||
|
const domain = useAppSelector((state) => state.meta.get('domain'));
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const dispatchRenderSignal = useRenderSignal();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchStatus(id, false, false));
|
||||||
|
}, [dispatch, id]);
|
||||||
|
|
||||||
|
const handleToggleHidden = useCallback(() => {
|
||||||
|
dispatch(toggleStatusSpoilers(id));
|
||||||
|
}, [dispatch, id]);
|
||||||
|
|
||||||
|
// This allows us to calculate the correct page height for embeds
|
||||||
|
if (status) {
|
||||||
|
dispatchRenderSignal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||||
|
const permalink = status?.get('url') as string;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='embed'>
|
||||||
|
<DetailedStatus
|
||||||
|
status={status}
|
||||||
|
domain={domain}
|
||||||
|
pictureInPicture={pictureInPicture}
|
||||||
|
onToggleHidden={handleToggleHidden}
|
||||||
|
expanded={false}
|
||||||
|
withLogo
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a
|
||||||
|
className='embed__overlay'
|
||||||
|
href={permalink}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer noopener'
|
||||||
|
aria-label=''
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Status: React.FC<{ id: string }> = ({ id }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialState) {
|
||||||
|
store.dispatch(hydrateStore(initialState));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntlProvider>
|
||||||
|
<Provider store={store}>
|
||||||
|
<Router>
|
||||||
|
<Embed id={id} />
|
||||||
|
</Router>
|
||||||
|
</Provider>
|
||||||
|
</IntlProvider>
|
||||||
|
);
|
||||||
|
};
|
|
@ -52,7 +52,7 @@ const messages = defineMessages({
|
||||||
share: { id: 'status.share', defaultMessage: 'Share' },
|
share: { id: 'status.share', defaultMessage: 'Share' },
|
||||||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
||||||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
||||||
embed: { id: 'status.embed', defaultMessage: 'Embed' },
|
embed: { id: 'status.embed', defaultMessage: 'Get embed code' },
|
||||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
||||||
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
|
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
|
||||||
|
|
|
@ -1,349 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { FormattedDate, FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Link, withRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
|
|
||||||
import { AnimatedNumber } from 'flavours/glitch/components/animated_number';
|
|
||||||
import AttachmentList from 'flavours/glitch/components/attachment_list';
|
|
||||||
import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
|
|
||||||
import { getHashtagBarForStatus } from 'flavours/glitch/components/hashtag_bar';
|
|
||||||
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
|
||||||
import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon';
|
|
||||||
import PollContainer from 'flavours/glitch/containers/poll_container';
|
|
||||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
|
||||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
|
||||||
|
|
||||||
import { Avatar } from '../../../components/avatar';
|
|
||||||
import { DisplayName } from '../../../components/display_name';
|
|
||||||
import MediaGallery from '../../../components/media_gallery';
|
|
||||||
import StatusContent from '../../../components/status_content';
|
|
||||||
import StatusReactions from '../../../components/status_reactions';
|
|
||||||
import { visibleReactions } from '../../../initial_state';
|
|
||||||
import Audio from '../../audio';
|
|
||||||
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
|
||||||
import Video from '../../video';
|
|
||||||
|
|
||||||
import Card from './card';
|
|
||||||
|
|
||||||
class DetailedStatus extends ImmutablePureComponent {
|
|
||||||
static propTypes = {
|
|
||||||
identity: identityContextPropShape,
|
|
||||||
status: ImmutablePropTypes.map,
|
|
||||||
settings: ImmutablePropTypes.map.isRequired,
|
|
||||||
onOpenMedia: PropTypes.func.isRequired,
|
|
||||||
onOpenVideo: PropTypes.func.isRequired,
|
|
||||||
onToggleHidden: PropTypes.func,
|
|
||||||
onTranslate: PropTypes.func.isRequired,
|
|
||||||
expanded: PropTypes.bool,
|
|
||||||
measureHeight: PropTypes.bool,
|
|
||||||
onHeightChange: PropTypes.func,
|
|
||||||
domain: PropTypes.string.isRequired,
|
|
||||||
compact: PropTypes.bool,
|
|
||||||
showMedia: PropTypes.bool,
|
|
||||||
pictureInPicture: ImmutablePropTypes.contains({
|
|
||||||
inUse: PropTypes.bool,
|
|
||||||
available: PropTypes.bool,
|
|
||||||
}),
|
|
||||||
onToggleMediaVisibility: PropTypes.func,
|
|
||||||
onReactionAdd: PropTypes.func.isRequired,
|
|
||||||
onReactionRemove: PropTypes.func.isRequired,
|
|
||||||
...WithRouterPropTypes,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
height: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleAccountClick = (e) => {
|
|
||||||
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.props.history) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
parseClick = (e, destination) => {
|
|
||||||
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.props.history) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.props.history.push(destination);
|
|
||||||
}
|
|
||||||
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleOpenVideo = (options) => {
|
|
||||||
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options);
|
|
||||||
};
|
|
||||||
|
|
||||||
_measureHeight (heightJustChanged) {
|
|
||||||
if (this.props.measureHeight && this.node) {
|
|
||||||
scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
|
|
||||||
|
|
||||||
if (this.props.onHeightChange && heightJustChanged) {
|
|
||||||
this.props.onHeightChange();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.node = c;
|
|
||||||
this._measureHeight();
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidUpdate (prevProps, prevState) {
|
|
||||||
this._measureHeight(prevState.height !== this.state.height);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChildUpdate = () => {
|
|
||||||
this._measureHeight();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleModalLink = e => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
let href;
|
|
||||||
|
|
||||||
if (e.target.nodeName !== 'A') {
|
|
||||||
href = e.target.parentNode.href;
|
|
||||||
} else {
|
|
||||||
href = e.target.href;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
|
|
||||||
};
|
|
||||||
|
|
||||||
handleTranslate = () => {
|
|
||||||
const { onTranslate, status } = this.props;
|
|
||||||
onTranslate(status);
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
|
|
||||||
const outerStyle = { boxSizing: 'border-box' };
|
|
||||||
const { compact, pictureInPicture, expanded, onToggleHidden, settings } = this.props;
|
|
||||||
|
|
||||||
if (!status) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let applicationLink = '';
|
|
||||||
let reblogLink = '';
|
|
||||||
let favouriteLink = '';
|
|
||||||
|
|
||||||
// Depending on user settings, some media are considered as parts of the
|
|
||||||
// contents (affected by CW) while other will be displayed outside of the
|
|
||||||
// CW.
|
|
||||||
let contentMedia = [];
|
|
||||||
let contentMediaIcons = [];
|
|
||||||
let extraMedia = [];
|
|
||||||
let extraMediaIcons = [];
|
|
||||||
let media = contentMedia;
|
|
||||||
let mediaIcons = contentMediaIcons;
|
|
||||||
|
|
||||||
if (settings.getIn(['content_warnings', 'media_outside'])) {
|
|
||||||
media = extraMedia;
|
|
||||||
mediaIcons = extraMediaIcons;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.props.measureHeight) {
|
|
||||||
outerStyle.height = `${this.state.height}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
|
||||||
|
|
||||||
if (pictureInPicture.get('inUse')) {
|
|
||||||
media.push(<PictureInPicturePlaceholder />);
|
|
||||||
mediaIcons.push('video-camera');
|
|
||||||
} else if (status.get('media_attachments').size > 0) {
|
|
||||||
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
|
||||||
media.push(<AttachmentList media={status.get('media_attachments')} />);
|
|
||||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
|
||||||
const attachment = status.getIn(['media_attachments', 0]);
|
|
||||||
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
|
||||||
|
|
||||||
media.push(
|
|
||||||
<Audio
|
|
||||||
src={attachment.get('url')}
|
|
||||||
alt={description}
|
|
||||||
lang={language}
|
|
||||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
|
||||||
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
|
||||||
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
|
||||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
|
||||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
|
||||||
sensitive={status.get('sensitive')}
|
|
||||||
visible={this.props.showMedia}
|
|
||||||
blurhash={attachment.get('blurhash')}
|
|
||||||
height={150}
|
|
||||||
onToggleVisibility={this.props.onToggleMediaVisibility}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
mediaIcons.push('music');
|
|
||||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
|
||||||
const attachment = status.getIn(['media_attachments', 0]);
|
|
||||||
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
|
||||||
|
|
||||||
media.push(
|
|
||||||
<Video
|
|
||||||
preview={attachment.get('preview_url')}
|
|
||||||
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
|
|
||||||
blurhash={attachment.get('blurhash')}
|
|
||||||
src={attachment.get('url')}
|
|
||||||
alt={description}
|
|
||||||
lang={language}
|
|
||||||
inline
|
|
||||||
sensitive={status.get('sensitive')}
|
|
||||||
letterbox={settings.getIn(['media', 'letterbox'])}
|
|
||||||
fullwidth={settings.getIn(['media', 'fullwidth'])}
|
|
||||||
preventPlayback={!expanded}
|
|
||||||
onOpenVideo={this.handleOpenVideo}
|
|
||||||
autoplay
|
|
||||||
visible={this.props.showMedia}
|
|
||||||
onToggleVisibility={this.props.onToggleMediaVisibility}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
mediaIcons.push('video-camera');
|
|
||||||
} else {
|
|
||||||
media.push(
|
|
||||||
<MediaGallery
|
|
||||||
standalone
|
|
||||||
sensitive={status.get('sensitive')}
|
|
||||||
media={status.get('media_attachments')}
|
|
||||||
lang={language}
|
|
||||||
letterbox={settings.getIn(['media', 'letterbox'])}
|
|
||||||
fullwidth={settings.getIn(['media', 'fullwidth'])}
|
|
||||||
hidden={!expanded}
|
|
||||||
onOpenMedia={this.props.onOpenMedia}
|
|
||||||
visible={this.props.showMedia}
|
|
||||||
onToggleVisibility={this.props.onToggleMediaVisibility}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
mediaIcons.push('picture-o');
|
|
||||||
}
|
|
||||||
} else if (status.get('card')) {
|
|
||||||
media.push(<Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />);
|
|
||||||
mediaIcons.push('link');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.get('poll')) {
|
|
||||||
contentMedia.push(<PollContainer pollId={status.get('poll')} lang={status.get('language')} />);
|
|
||||||
contentMediaIcons.push('tasks');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.get('application')) {
|
|
||||||
applicationLink = <>·<a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibilityLink = <>·<VisibilityIcon visibility={status.get('visibility')} /></>;
|
|
||||||
|
|
||||||
if (!['unlisted', 'public'].includes(status.get('visibility'))) {
|
|
||||||
reblogLink = null;
|
|
||||||
} else if (this.props.history) {
|
|
||||||
reblogLink = (
|
|
||||||
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'>
|
|
||||||
<span className='detailed-status__reblogs'>
|
|
||||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
|
||||||
</span>
|
|
||||||
<FormattedMessage id='status.reblogs' defaultMessage='{count, plural, one {boost} other {boosts}}' values={{ count: status.get('reblogs_count') }} />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
reblogLink = (
|
|
||||||
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
|
|
||||||
<span className='detailed-status__reblogs'>
|
|
||||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
|
||||||
</span>
|
|
||||||
<FormattedMessage id='status.reblogs' defaultMessage='{count, plural, one {boost} other {boosts}}' values={{ count: status.get('reblogs_count') }} />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.props.history) {
|
|
||||||
favouriteLink = (
|
|
||||||
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`} className='detailed-status__link'>
|
|
||||||
<span className='detailed-status__favorites'>
|
|
||||||
<AnimatedNumber value={status.get('favourites_count')} />
|
|
||||||
</span>
|
|
||||||
<FormattedMessage id='status.favourites' defaultMessage='{count, plural, one {favorite} other {favorites}}' values={{ count: status.get('favourites_count') }} />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
favouriteLink = (
|
|
||||||
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
|
|
||||||
<span className='detailed-status__favorites'>
|
|
||||||
<AnimatedNumber value={status.get('favourites_count')} />
|
|
||||||
</span>
|
|
||||||
<FormattedMessage id='status.favourites' defaultMessage='{count, plural, one {favorite} other {favorites}}' values={{ count: status.get('favourites_count') }} />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
|
||||||
contentMedia.push(hashtagBar);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={outerStyle}>
|
|
||||||
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
|
|
||||||
<a href={status.getIn(['account', 'url'])} data-hover-card-account={status.getIn(['account', 'id'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
|
||||||
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
|
|
||||||
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<StatusContent
|
|
||||||
status={status}
|
|
||||||
media={contentMedia}
|
|
||||||
extraMedia={extraMedia}
|
|
||||||
mediaIcons={contentMediaIcons}
|
|
||||||
expanded={expanded}
|
|
||||||
collapsed={false}
|
|
||||||
onExpandedToggle={onToggleHidden}
|
|
||||||
onTranslate={this.handleTranslate}
|
|
||||||
parseClick={this.parseClick}
|
|
||||||
onUpdate={this.handleChildUpdate}
|
|
||||||
tagLinks={settings.get('tag_misleading_links')}
|
|
||||||
rewriteMentions={settings.get('rewrite_mentions')}
|
|
||||||
disabled
|
|
||||||
{...statusContentProps}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{visibleReactions > 0 && (<StatusReactions
|
|
||||||
statusId={status.get('id')}
|
|
||||||
reactions={status.get('reactions')}
|
|
||||||
addReaction={this.props.onReactionAdd}
|
|
||||||
removeReaction={this.props.onReactionRemove}
|
|
||||||
canReact={this.props.identity.signedIn}
|
|
||||||
/>)}
|
|
||||||
|
|
||||||
<div className='detailed-status__meta'>
|
|
||||||
<div className='detailed-status__meta__line'>
|
|
||||||
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
|
|
||||||
<FormattedDate value={new Date(status.get('created_at'))} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{visibilityLink}
|
|
||||||
|
|
||||||
{applicationLink}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{status.get('edited_at') && <div className='detailed-status__meta__line'><EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} /></div>}
|
|
||||||
|
|
||||||
<div className='detailed-status__meta__line'>
|
|
||||||
{reblogLink}
|
|
||||||
{reblogLink && <>·</>}
|
|
||||||
{favouriteLink}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withRouter(withIdentity(DetailedStatus));
|
|
|
@ -0,0 +1,445 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access,
|
||||||
|
@typescript-eslint/no-unsafe-call,
|
||||||
|
@typescript-eslint/no-explicit-any,
|
||||||
|
@typescript-eslint/no-unsafe-assignment */
|
||||||
|
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { FormattedDate, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { AnimatedNumber } from 'flavours/glitch/components/animated_number';
|
||||||
|
import AttachmentList from 'flavours/glitch/components/attachment_list';
|
||||||
|
import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
|
||||||
|
import type { StatusLike } from 'flavours/glitch/components/hashtag_bar';
|
||||||
|
import { getHashtagBarForStatus } from 'flavours/glitch/components/hashtag_bar';
|
||||||
|
import { IconLogo } from 'flavours/glitch/components/logo';
|
||||||
|
import { Permalink } from 'flavours/glitch/components/permalink';
|
||||||
|
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
||||||
|
import { useAppHistory } from 'flavours/glitch/components/router';
|
||||||
|
import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon';
|
||||||
|
import { useIdentity } from 'flavours/glitch/identity_context';
|
||||||
|
import { useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
import { Avatar } from '../../../components/avatar';
|
||||||
|
import { DisplayName } from '../../../components/display_name';
|
||||||
|
import MediaGallery from '../../../components/media_gallery';
|
||||||
|
import StatusContent from '../../../components/status_content';
|
||||||
|
import StatusReactions from '../../../components/status_reactions';
|
||||||
|
import { visibleReactions } from '../../../initial_state';
|
||||||
|
import Audio from '../../audio';
|
||||||
|
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
||||||
|
import Video from '../../video';
|
||||||
|
|
||||||
|
import Card from './card';
|
||||||
|
|
||||||
|
interface VideoModalOptions {
|
||||||
|
startTime: number;
|
||||||
|
autoPlay?: boolean;
|
||||||
|
defaultVolume: number;
|
||||||
|
componentIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DetailedStatus: React.FC<{
|
||||||
|
status: any;
|
||||||
|
onOpenMedia?: (status: any, index: number, lang: string) => void;
|
||||||
|
onOpenVideo?: (status: any, lang: string, options: VideoModalOptions) => void;
|
||||||
|
onTranslate?: (status: any) => void;
|
||||||
|
measureHeight?: boolean;
|
||||||
|
onHeightChange?: () => void;
|
||||||
|
domain: string;
|
||||||
|
showMedia?: boolean;
|
||||||
|
withLogo?: boolean;
|
||||||
|
pictureInPicture: any;
|
||||||
|
onToggleHidden?: (status: any) => void;
|
||||||
|
onToggleMediaVisibility?: () => void;
|
||||||
|
onReactionAdd?: (status: any, name: string, url: string) => void;
|
||||||
|
onReactionRemove?: (status: any, name: string) => void;
|
||||||
|
expanded: boolean;
|
||||||
|
}> = ({
|
||||||
|
status,
|
||||||
|
onOpenMedia,
|
||||||
|
onOpenVideo,
|
||||||
|
onTranslate,
|
||||||
|
measureHeight,
|
||||||
|
onHeightChange,
|
||||||
|
domain,
|
||||||
|
showMedia,
|
||||||
|
withLogo,
|
||||||
|
pictureInPicture,
|
||||||
|
onToggleMediaVisibility,
|
||||||
|
onToggleHidden,
|
||||||
|
onReactionAdd,
|
||||||
|
onReactionRemove,
|
||||||
|
expanded,
|
||||||
|
}) => {
|
||||||
|
const properStatus = status?.get('reblog') ?? status;
|
||||||
|
const [height, setHeight] = useState(0);
|
||||||
|
const nodeRef = useRef<HTMLDivElement>();
|
||||||
|
const history = useAppHistory();
|
||||||
|
const { signedIn } = useIdentity();
|
||||||
|
|
||||||
|
const rewriteMentions = useAppSelector(
|
||||||
|
(state) => state.local_settings.get('rewrite_mentions', false) as boolean,
|
||||||
|
);
|
||||||
|
const tagMisleadingLinks = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
state.local_settings.get('tag_misleading_links', false) as boolean,
|
||||||
|
);
|
||||||
|
const mediaOutsideCW = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
state.local_settings.getIn(
|
||||||
|
['content_warnings', 'media_outside'],
|
||||||
|
false,
|
||||||
|
) as boolean,
|
||||||
|
);
|
||||||
|
const letterboxMedia = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
state.local_settings.getIn(['media', 'letterbox'], false) as boolean,
|
||||||
|
);
|
||||||
|
const fullwidthMedia = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
state.local_settings.getIn(['media', 'fullwidth'], false) as boolean,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenVideo = useCallback(
|
||||||
|
(options: VideoModalOptions) => {
|
||||||
|
const lang = (status.getIn(['translation', 'language']) ||
|
||||||
|
status.get('language')) as string;
|
||||||
|
if (onOpenVideo)
|
||||||
|
onOpenVideo(status.getIn(['media_attachments', 0]), lang, options);
|
||||||
|
},
|
||||||
|
[onOpenVideo, status],
|
||||||
|
);
|
||||||
|
|
||||||
|
const _measureHeight = useCallback(
|
||||||
|
(heightJustChanged?: boolean) => {
|
||||||
|
if (measureHeight && nodeRef.current) {
|
||||||
|
scheduleIdleTask(() => {
|
||||||
|
if (nodeRef.current)
|
||||||
|
setHeight(Math.ceil(nodeRef.current.scrollHeight) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onHeightChange && heightJustChanged) {
|
||||||
|
onHeightChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onHeightChange, measureHeight, setHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRef = useCallback(
|
||||||
|
(c: HTMLDivElement) => {
|
||||||
|
nodeRef.current = c;
|
||||||
|
_measureHeight();
|
||||||
|
},
|
||||||
|
[_measureHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChildUpdate = useCallback(() => {
|
||||||
|
_measureHeight();
|
||||||
|
}, [_measureHeight]);
|
||||||
|
|
||||||
|
const handleTranslate = useCallback(() => {
|
||||||
|
if (onTranslate) onTranslate(status);
|
||||||
|
}, [onTranslate, status]);
|
||||||
|
|
||||||
|
const parseClick = useCallback(
|
||||||
|
(e: React.MouseEvent, destination: string) => {
|
||||||
|
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
history.push(destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
[history],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!properStatus) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let applicationLink;
|
||||||
|
let reblogLink;
|
||||||
|
|
||||||
|
// Depending on user settings, some media are considered as parts of the
|
||||||
|
// contents (affected by CW) while other will be displayed outside of the
|
||||||
|
// CW.
|
||||||
|
const contentMedia: React.ReactNode[] = [];
|
||||||
|
const contentMediaIcons: string[] = [];
|
||||||
|
const extraMedia: React.ReactNode[] = [];
|
||||||
|
const extraMediaIcons: string[] = [];
|
||||||
|
let media = contentMedia;
|
||||||
|
let mediaIcons: string[] = contentMediaIcons;
|
||||||
|
|
||||||
|
if (mediaOutsideCW) {
|
||||||
|
media = extraMedia;
|
||||||
|
mediaIcons = extraMediaIcons;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outerStyle = { boxSizing: 'border-box' } as CSSProperties;
|
||||||
|
|
||||||
|
if (measureHeight) {
|
||||||
|
outerStyle.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
const language =
|
||||||
|
status.getIn(['translation', 'language']) || status.get('language');
|
||||||
|
|
||||||
|
if (pictureInPicture.get('inUse')) {
|
||||||
|
media.push(<PictureInPicturePlaceholder />);
|
||||||
|
mediaIcons.push('video-camera');
|
||||||
|
} else if (status.get('media_attachments').size > 0) {
|
||||||
|
if (
|
||||||
|
status
|
||||||
|
.get('media_attachments')
|
||||||
|
.some(
|
||||||
|
(item: Immutable.Map<string, any>) => item.get('type') === 'unknown',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
media.push(<AttachmentList media={status.get('media_attachments')} />);
|
||||||
|
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||||
|
const attachment = status.getIn(['media_attachments', 0]);
|
||||||
|
const description =
|
||||||
|
attachment.getIn(['translation', 'description']) ||
|
||||||
|
attachment.get('description');
|
||||||
|
|
||||||
|
media.push(
|
||||||
|
<Audio
|
||||||
|
src={attachment.get('url')}
|
||||||
|
alt={description}
|
||||||
|
lang={language}
|
||||||
|
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
|
poster={
|
||||||
|
attachment.get('preview_url') ||
|
||||||
|
status.getIn(['account', 'avatar_static'])
|
||||||
|
}
|
||||||
|
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||||
|
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||||
|
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||||
|
sensitive={status.get('sensitive')}
|
||||||
|
visible={showMedia}
|
||||||
|
blurhash={attachment.get('blurhash')}
|
||||||
|
height={150}
|
||||||
|
onToggleVisibility={onToggleMediaVisibility}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
mediaIcons.push('music');
|
||||||
|
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
|
const attachment = status.getIn(['media_attachments', 0]);
|
||||||
|
const description =
|
||||||
|
attachment.getIn(['translation', 'description']) ||
|
||||||
|
attachment.get('description');
|
||||||
|
|
||||||
|
media.push(
|
||||||
|
<Video
|
||||||
|
preview={attachment.get('preview_url')}
|
||||||
|
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
|
||||||
|
aspectRatio={`${attachment.getIn(['meta', 'original', 'width'])} / ${attachment.getIn(['meta', 'original', 'height'])}`}
|
||||||
|
blurhash={attachment.get('blurhash')}
|
||||||
|
src={attachment.get('url')}
|
||||||
|
alt={description}
|
||||||
|
lang={language}
|
||||||
|
width={300}
|
||||||
|
height={150}
|
||||||
|
onOpenVideo={handleOpenVideo}
|
||||||
|
sensitive={status.get('sensitive')}
|
||||||
|
visible={showMedia}
|
||||||
|
onToggleVisibility={onToggleMediaVisibility}
|
||||||
|
letterbox={letterboxMedia}
|
||||||
|
fullwidth={fullwidthMedia}
|
||||||
|
preventPlayback={!expanded}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
mediaIcons.push('video-camera');
|
||||||
|
} else {
|
||||||
|
media.push(
|
||||||
|
<MediaGallery
|
||||||
|
standalone
|
||||||
|
sensitive={status.get('sensitive')}
|
||||||
|
media={status.get('media_attachments')}
|
||||||
|
lang={language}
|
||||||
|
height={300}
|
||||||
|
letterbox={letterboxMedia}
|
||||||
|
fullwidth={fullwidthMedia}
|
||||||
|
hidden={!expanded}
|
||||||
|
onOpenMedia={onOpenMedia}
|
||||||
|
visible={showMedia}
|
||||||
|
onToggleVisibility={onToggleMediaVisibility}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
mediaIcons.push('picture-o');
|
||||||
|
}
|
||||||
|
} else if (status.get('spoiler_text').length === 0) {
|
||||||
|
media.push(
|
||||||
|
<Card
|
||||||
|
sensitive={status.get('sensitive')}
|
||||||
|
onOpenMedia={onOpenMedia}
|
||||||
|
card={status.get('card', null)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
mediaIcons.push('link');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.get('application')) {
|
||||||
|
applicationLink = (
|
||||||
|
<>
|
||||||
|
·
|
||||||
|
<a
|
||||||
|
className='detailed-status__application'
|
||||||
|
href={status.getIn(['application', 'website'])}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
{status.getIn(['application', 'name'])}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibilityLink = (
|
||||||
|
<>
|
||||||
|
·<VisibilityIcon visibility={status.get('visibility')} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (['private', 'direct'].includes(status.get('visibility') as string)) {
|
||||||
|
reblogLink = '';
|
||||||
|
} else {
|
||||||
|
reblogLink = (
|
||||||
|
<Link
|
||||||
|
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`}
|
||||||
|
className='detailed-status__link'
|
||||||
|
>
|
||||||
|
<span className='detailed-status__reblogs'>
|
||||||
|
<AnimatedNumber value={status.get('reblogs_count')} />
|
||||||
|
</span>
|
||||||
|
<FormattedMessage
|
||||||
|
id='status.reblogs'
|
||||||
|
defaultMessage='{count, plural, one {boost} other {boosts}}'
|
||||||
|
values={{ count: status.get('reblogs_count') }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const favouriteLink = (
|
||||||
|
<Link
|
||||||
|
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`}
|
||||||
|
className='detailed-status__link'
|
||||||
|
>
|
||||||
|
<span className='detailed-status__favorites'>
|
||||||
|
<AnimatedNumber value={status.get('favourites_count')} />
|
||||||
|
</span>
|
||||||
|
<FormattedMessage
|
||||||
|
id='status.favourites'
|
||||||
|
defaultMessage='{count, plural, one {favorite} other {favorites}}'
|
||||||
|
values={{ count: status.get('favourites_count') }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { statusContentProps, hashtagBar } = getHashtagBarForStatus(
|
||||||
|
status as StatusLike,
|
||||||
|
);
|
||||||
|
contentMedia.push(hashtagBar);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={outerStyle}>
|
||||||
|
<div
|
||||||
|
ref={handleRef}
|
||||||
|
className={classNames(
|
||||||
|
'detailed-status',
|
||||||
|
`detailed-status-${status.get('visibility')}`,
|
||||||
|
)}
|
||||||
|
data-status-by={status.getIn(['account', 'acct'])}
|
||||||
|
>
|
||||||
|
<Permalink
|
||||||
|
to={`/@${status.getIn(['account', 'acct'])}`}
|
||||||
|
href={status.getIn(['account', 'url'])}
|
||||||
|
data-hover-card-account={status.getIn(['account', 'id'])}
|
||||||
|
className='detailed-status__display-name'
|
||||||
|
>
|
||||||
|
<div className='detailed-status__display-avatar'>
|
||||||
|
<Avatar account={status.get('account')} size={46} />
|
||||||
|
</div>
|
||||||
|
<DisplayName account={status.get('account')} localDomain={domain} />
|
||||||
|
{withLogo && (
|
||||||
|
<>
|
||||||
|
<div className='spacer' />
|
||||||
|
<IconLogo />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Permalink>
|
||||||
|
|
||||||
|
<StatusContent
|
||||||
|
status={status}
|
||||||
|
media={contentMedia}
|
||||||
|
extraMedia={extraMedia}
|
||||||
|
mediaIcons={contentMediaIcons}
|
||||||
|
expanded={expanded}
|
||||||
|
collapsed={false}
|
||||||
|
onExpandedToggle={onToggleHidden}
|
||||||
|
onTranslate={handleTranslate}
|
||||||
|
onUpdate={handleChildUpdate}
|
||||||
|
tagLinks={tagMisleadingLinks}
|
||||||
|
rewriteMentions={rewriteMentions}
|
||||||
|
parseClick={parseClick}
|
||||||
|
{...(statusContentProps as any)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{visibleReactions && visibleReactions > 0 && (
|
||||||
|
<StatusReactions
|
||||||
|
statusId={status.get('id')}
|
||||||
|
reactions={status.get('reactions')}
|
||||||
|
addReaction={onReactionAdd}
|
||||||
|
removeReaction={onReactionRemove}
|
||||||
|
canReact={signedIn}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='detailed-status__meta'>
|
||||||
|
<div className='detailed-status__meta__line'>
|
||||||
|
<a
|
||||||
|
className='detailed-status__datetime'
|
||||||
|
href={status.get('url')}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
<FormattedDate
|
||||||
|
value={new Date(status.get('created_at') as string)}
|
||||||
|
year='numeric'
|
||||||
|
month='short'
|
||||||
|
day='2-digit'
|
||||||
|
hour='2-digit'
|
||||||
|
minute='2-digit'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{visibilityLink}
|
||||||
|
{applicationLink}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.get('edited_at') && (
|
||||||
|
<div className='detailed-status__meta__line'>
|
||||||
|
<EditedTimestamp
|
||||||
|
statusId={status.get('id')}
|
||||||
|
timestamp={status.get('edited_at')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='detailed-status__meta__line'>
|
||||||
|
{reblogLink}
|
||||||
|
{reblogLink && <>·</>}
|
||||||
|
{favouriteLink}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,134 +0,0 @@
|
||||||
import { injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { showAlertForError } from '../../../actions/alerts';
|
|
||||||
import { initBlockModal } from '../../../actions/blocks';
|
|
||||||
import {
|
|
||||||
replyCompose,
|
|
||||||
mentionCompose,
|
|
||||||
directCompose,
|
|
||||||
} from '../../../actions/compose';
|
|
||||||
import {
|
|
||||||
toggleReblog,
|
|
||||||
toggleFavourite,
|
|
||||||
pin,
|
|
||||||
unpin,
|
|
||||||
} from '../../../actions/interactions';
|
|
||||||
import { openModal } from '../../../actions/modal';
|
|
||||||
import { initMuteModal } from '../../../actions/mutes';
|
|
||||||
import { initReport } from '../../../actions/reports';
|
|
||||||
import {
|
|
||||||
muteStatus,
|
|
||||||
unmuteStatus,
|
|
||||||
deleteStatus,
|
|
||||||
} from '../../../actions/statuses';
|
|
||||||
import { deleteModal } from '../../../initial_state';
|
|
||||||
import { makeGetStatus } from '../../../selectors';
|
|
||||||
import DetailedStatus from '../components/detailed_status';
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const getStatus = makeGetStatus();
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
|
||||||
status: getStatus(state, props),
|
|
||||||
domain: state.getIn(['meta', 'domain']),
|
|
||||||
settings: state.get('local_settings'),
|
|
||||||
});
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
|
||||||
|
|
||||||
onReply (status) {
|
|
||||||
dispatch((_, getState) => {
|
|
||||||
let state = getState();
|
|
||||||
if (state.getIn(['compose', 'text']).trim().length !== 0) {
|
|
||||||
dispatch(openModal({ modalType: 'CONFIRM_REPLY', modalProps: { status } }));
|
|
||||||
} else {
|
|
||||||
dispatch(replyCompose(status));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
onReblog (status, e) {
|
|
||||||
dispatch(toggleReblog(status.get('id'), e.shiftKey));
|
|
||||||
},
|
|
||||||
|
|
||||||
onFavourite (status, e) {
|
|
||||||
dispatch(toggleFavourite(status.get('id'), e.shiftKey));
|
|
||||||
},
|
|
||||||
|
|
||||||
onPin (status) {
|
|
||||||
if (status.get('pinned')) {
|
|
||||||
dispatch(unpin(status));
|
|
||||||
} else {
|
|
||||||
dispatch(pin(status));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onEmbed (status) {
|
|
||||||
dispatch(openModal({
|
|
||||||
modalType: 'EMBED',
|
|
||||||
modalProps: {
|
|
||||||
id: status.get('id'),
|
|
||||||
onError: error => dispatch(showAlertForError(error)),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
onDelete (status, withRedraft = false) {
|
|
||||||
if (!deleteModal) {
|
|
||||||
dispatch(deleteStatus(status.get('id'), withRedraft));
|
|
||||||
} else {
|
|
||||||
dispatch(openModal({ modalType: 'CONFIRM_DELETE_STATUS', modalProps: { statusId: status.get('id'), withRedraft } }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onDirect (account) {
|
|
||||||
dispatch(directCompose(account));
|
|
||||||
},
|
|
||||||
|
|
||||||
onMention (account) {
|
|
||||||
dispatch(mentionCompose(account));
|
|
||||||
},
|
|
||||||
|
|
||||||
onOpenMedia (media, index, lang) {
|
|
||||||
dispatch(openModal({
|
|
||||||
modalType: 'MEDIA',
|
|
||||||
modalProps: { media, index, lang },
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
onOpenVideo (media, lang, options) {
|
|
||||||
dispatch(openModal({
|
|
||||||
modalType: 'VIDEO',
|
|
||||||
modalProps: { media, lang, options },
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
onBlock (status) {
|
|
||||||
const account = status.get('account');
|
|
||||||
dispatch(initBlockModal(account));
|
|
||||||
},
|
|
||||||
|
|
||||||
onReport (status) {
|
|
||||||
dispatch(initReport(status.get('account'), status));
|
|
||||||
},
|
|
||||||
|
|
||||||
onMute (account) {
|
|
||||||
dispatch(initMuteModal(account));
|
|
||||||
},
|
|
||||||
|
|
||||||
onMuteConversation (status) {
|
|
||||||
if (status.get('muted')) {
|
|
||||||
dispatch(unmuteStatus(status.get('id')));
|
|
||||||
} else {
|
|
||||||
dispatch(muteStatus(status.get('id')));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
|
|
|
@ -65,7 +65,7 @@ import Column from '../ui/components/column';
|
||||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
|
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
|
||||||
|
|
||||||
import ActionBar from './components/action_bar';
|
import ActionBar from './components/action_bar';
|
||||||
import DetailedStatus from './components/detailed_status';
|
import { DetailedStatus } from './components/detailed_status';
|
||||||
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
|
|
@ -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}>
|
<Button onClick={handleClick} autoFocus>
|
||||||
<FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' />
|
<FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -64,8 +64,8 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||||
openSettings: PropTypes.func,
|
openSettings: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Corresponds to (max-width: $no-gap-breakpoint + 285px - 1px) in SCSS
|
// Corresponds to (max-width: $no-gap-breakpoint - 1px) in SCSS
|
||||||
mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1174px)');
|
mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1206px)');
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
|
renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
|
||||||
|
|
|
@ -79,7 +79,10 @@ export const ConfirmationModal: React.FC<
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Button onClick={handleClick}>{confirm}</Button>
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */}
|
||||||
|
<Button onClick={handleClick} autoFocus>
|
||||||
|
{confirm}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -88,7 +88,7 @@ export const DomainBlockModal = ({ domain, accountId, acct }) => {
|
||||||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Button onClick={handleClick}>
|
<Button onClick={handleClick} autoFocus>
|
||||||
<FormattedMessage id='domain_block_modal.block' defaultMessage='Block server' />
|
<FormattedMessage id='domain_block_modal.block' defaultMessage='Block server' />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,101 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
|
||||||
import api from 'flavours/glitch/api';
|
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
|
||||||
});
|
|
||||||
|
|
||||||
class EmbedModal extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
id: PropTypes.string.isRequired,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
onError: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
loading: false,
|
|
||||||
oembed: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
const { id } = this.props;
|
|
||||||
|
|
||||||
this.setState({ loading: true });
|
|
||||||
|
|
||||||
api().get(`/api/web/embeds/${id}`).then(res => {
|
|
||||||
this.setState({ loading: false, oembed: res.data });
|
|
||||||
|
|
||||||
const iframeDocument = this.iframe.contentWindow.document;
|
|
||||||
|
|
||||||
iframeDocument.open();
|
|
||||||
iframeDocument.write(res.data.html);
|
|
||||||
iframeDocument.close();
|
|
||||||
|
|
||||||
iframeDocument.body.style.margin = 0;
|
|
||||||
this.iframe.width = iframeDocument.body.scrollWidth;
|
|
||||||
this.iframe.height = iframeDocument.body.scrollHeight;
|
|
||||||
}).catch(error => {
|
|
||||||
this.props.onError(error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setIframeRef = c => {
|
|
||||||
this.iframe = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleTextareaClick = (e) => {
|
|
||||||
e.target.select();
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { intl, onClose } = this.props;
|
|
||||||
const { oembed } = this.state;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='modal-root__modal report-modal embed-modal'>
|
|
||||||
<div className='report-modal__target'>
|
|
||||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={16} />
|
|
||||||
<FormattedMessage id='status.embed' defaultMessage='Embed' />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='report-modal__container embed-modal__container' style={{ display: 'block' }}>
|
|
||||||
<p className='hint'>
|
|
||||||
<FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type='text'
|
|
||||||
className='embed-modal__html'
|
|
||||||
readOnly
|
|
||||||
value={oembed && oembed.html || ''}
|
|
||||||
onClick={this.handleTextareaClick}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className='hint'>
|
|
||||||
<FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<iframe
|
|
||||||
className='embed-modal__iframe'
|
|
||||||
frameBorder='0'
|
|
||||||
ref={this.setIframeRef}
|
|
||||||
sandbox='allow-scripts allow-same-origin'
|
|
||||||
title='preview'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(EmbedModal);
|
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { useRef, useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { showAlertForError } from 'flavours/glitch/actions/alerts';
|
||||||
|
import api from 'flavours/glitch/api';
|
||||||
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
|
import { CopyPasteText } from 'flavours/glitch/components/copy_paste_text';
|
||||||
|
import { useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
interface OEmbedResponse {
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmbedModal: React.FC<{
|
||||||
|
id: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = ({ id, onClose }) => {
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval>>();
|
||||||
|
const [oembed, setOembed] = useState<OEmbedResponse | null>(null);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api()
|
||||||
|
.get(`/api/web/embeds/${id}`)
|
||||||
|
.then((res) => {
|
||||||
|
const data = res.data as OEmbedResponse;
|
||||||
|
|
||||||
|
setOembed(data);
|
||||||
|
|
||||||
|
const iframeDocument = iframeRef.current?.contentWindow?.document;
|
||||||
|
|
||||||
|
if (!iframeDocument) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
iframeDocument.open();
|
||||||
|
iframeDocument.write(data.html);
|
||||||
|
iframeDocument.close();
|
||||||
|
|
||||||
|
iframeDocument.body.style.margin = '0px';
|
||||||
|
|
||||||
|
// This is our best chance to ensure the parent iframe has the correct height...
|
||||||
|
intervalRef.current = setInterval(
|
||||||
|
() =>
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (iframeRef.current) {
|
||||||
|
iframeRef.current.width = `${iframeDocument.body.scrollWidth}px`;
|
||||||
|
iframeRef.current.height = `${iframeDocument.body.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
dispatch(showAlertForError(error));
|
||||||
|
});
|
||||||
|
}, [dispatch, id, setOembed]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='modal-root__modal dialog-modal'>
|
||||||
|
<div className='dialog-modal__header'>
|
||||||
|
<Button onClick={onClose}>
|
||||||
|
<FormattedMessage id='report.close' defaultMessage='Done' />
|
||||||
|
</Button>
|
||||||
|
<span className='dialog-modal__header__title'>
|
||||||
|
<FormattedMessage id='status.embed' defaultMessage='Get embed code' />
|
||||||
|
</span>
|
||||||
|
<Button secondary onClick={onClose}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmation_modal.cancel'
|
||||||
|
defaultMessage='Cancel'
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='dialog-modal__content'>
|
||||||
|
<div className='dialog-modal__content__form'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='embed.instructions'
|
||||||
|
defaultMessage='Embed this status on your website by copying the code below.'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CopyPasteText value={oembed?.html ?? ''} />
|
||||||
|
|
||||||
|
<FormattedMessage
|
||||||
|
id='embed.preview'
|
||||||
|
defaultMessage='Here is what it will look like:'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<iframe
|
||||||
|
frameBorder='0'
|
||||||
|
ref={iframeRef}
|
||||||
|
sandbox='allow-scripts allow-same-origin'
|
||||||
|
title='Preview'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default EmbedModal;
|
|
@ -137,7 +137,7 @@ export const MuteModal = ({ accountId, acct }) => {
|
||||||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Button onClick={handleClick}>
|
<Button onClick={handleClick} autoFocus>
|
||||||
<FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' />
|
<FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -36,7 +36,6 @@ import { timelinePreview, trendsEnabled } from 'flavours/glitch/initial_state';
|
||||||
import { transientSingleColumn } from 'flavours/glitch/is_mobile';
|
import { transientSingleColumn } from 'flavours/glitch/is_mobile';
|
||||||
import { canManageReports, canViewAdminDashboard } from 'flavours/glitch/permissions';
|
import { canManageReports, canViewAdminDashboard } from 'flavours/glitch/permissions';
|
||||||
import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications';
|
import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications';
|
||||||
import { selectUseGroupedNotifications } from 'flavours/glitch/selectors/settings';
|
|
||||||
import { preferencesLink } from 'flavours/glitch/utils/backend_links';
|
import { preferencesLink } from 'flavours/glitch/utils/backend_links';
|
||||||
|
|
||||||
import ColumnLink from './column_link';
|
import ColumnLink from './column_link';
|
||||||
|
@ -66,19 +65,17 @@ const messages = defineMessages({
|
||||||
});
|
});
|
||||||
|
|
||||||
const NotificationsLink = () => {
|
const NotificationsLink = () => {
|
||||||
const optedInGroupedNotifications = useSelector(selectUseGroupedNotifications);
|
const count = useSelector(selectUnreadNotificationGroupsCount);
|
||||||
const count = useSelector(state => state.getIn(['local_settings', 'notifications', 'tab_badge']) ? state.getIn(['notifications', 'unread']) : 0);
|
const showCount = useSelector(state => state.getIn(['local_settings', 'notifications', 'tab_badge']));
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const newCount = useSelector(selectUnreadNotificationGroupsCount);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ColumnLink
|
<ColumnLink
|
||||||
key='notifications'
|
key='notifications'
|
||||||
transparent
|
transparent
|
||||||
to='/notifications'
|
to='/notifications'
|
||||||
icon={<IconWithBadge id='bell' icon={NotificationsIcon} count={optedInGroupedNotifications ? newCount : count} className='column-link__icon' />}
|
icon={<IconWithBadge id='bell' icon={NotificationsIcon} count={showCount ? count : 0} className='column-link__icon' />}
|
||||||
activeIcon={<IconWithBadge id='bell' icon={NotificationsActiveIcon} count={optedInGroupedNotifications ? newCount : count} className='column-link__icon' />}
|
activeIcon={<IconWithBadge id='bell' icon={NotificationsActiveIcon} count={showCount ? count : 0} className='column-link__icon' />}
|
||||||
text={intl.formatMessage(messages.notifications)}
|
text={intl.formatMessage(messages.notifications)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,10 +3,12 @@ import { connect } from 'react-redux';
|
||||||
import { openModal, closeModal } from '../../../actions/modal';
|
import { openModal, closeModal } from '../../../actions/modal';
|
||||||
import ModalRoot from '../components/modal_root';
|
import ModalRoot from '../components/modal_root';
|
||||||
|
|
||||||
|
const defaultProps = {};
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
ignoreFocus: state.getIn(['modal', 'ignoreFocus']),
|
ignoreFocus: state.getIn(['modal', 'ignoreFocus']),
|
||||||
type: state.getIn(['modal', 'stack', 0, 'modalType'], null),
|
type: state.getIn(['modal', 'stack', 0, 'modalType'], null),
|
||||||
props: state.getIn(['modal', 'stack', 0, 'modalProps'], {}),
|
props: state.getIn(['modal', 'stack', 0, 'modalProps'], defaultProps),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
|
|
@ -4,24 +4,11 @@ import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { NotificationStack } from 'react-notification';
|
import { NotificationStack } from 'react-notification';
|
||||||
|
|
||||||
import { dismissAlert } from '../../../actions/alerts';
|
import { dismissAlert } from 'flavours/glitch/actions/alerts';
|
||||||
import { getAlerts } from '../../../selectors';
|
import { getAlerts } from 'flavours/glitch/selectors';
|
||||||
|
|
||||||
const formatIfNeeded = (intl, message, values) => {
|
|
||||||
if (typeof message === 'object') {
|
|
||||||
return intl.formatMessage(message, values);
|
|
||||||
}
|
|
||||||
|
|
||||||
return message;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { intl }) => ({
|
const mapStateToProps = (state, { intl }) => ({
|
||||||
notifications: getAlerts(state).map(alert => ({
|
notifications: getAlerts(state, { intl }),
|
||||||
...alert,
|
|
||||||
action: formatIfNeeded(intl, alert.action, alert.values),
|
|
||||||
title: formatIfNeeded(intl, alert.title, alert.values),
|
|
||||||
message: formatIfNeeded(intl, alert.message, alert.values),
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
|
|
|
@ -15,7 +15,7 @@ const getRegex = createSelector([
|
||||||
|
|
||||||
try {
|
try {
|
||||||
regex = rawRegex && new RegExp(rawRegex.trim(), 'i');
|
regex = rawRegex && new RegExp(rawRegex.trim(), 'i');
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Bad regex, don't affect filters
|
// Bad regex, don't affect filters
|
||||||
}
|
}
|
||||||
return regex;
|
return regex;
|
||||||
|
|
|
@ -337,8 +337,8 @@ class UI extends PureComponent {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
} catch (err) {
|
} catch {
|
||||||
|
// do nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|
32
app/javascript/flavours/glitch/hooks/useRenderSignal.ts
Normal file
32
app/javascript/flavours/glitch/hooks/useRenderSignal.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
// This hook allows a component to signal that it's done rendering in a way that
|
||||||
|
// can be used by e.g. our embed code to determine correct iframe height
|
||||||
|
|
||||||
|
let renderSignalReceived = false;
|
||||||
|
|
||||||
|
type Callback = () => void;
|
||||||
|
|
||||||
|
let onInitialRender: Callback;
|
||||||
|
|
||||||
|
export const afterInitialRender = (callback: Callback) => {
|
||||||
|
if (renderSignalReceived) {
|
||||||
|
callback();
|
||||||
|
} else {
|
||||||
|
onInitialRender = callback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRenderSignal = () => {
|
||||||
|
return () => {
|
||||||
|
if (renderSignalReceived) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSignalReceived = true;
|
||||||
|
|
||||||
|
if (typeof onInitialRender !== 'undefined') {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
onInitialRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
|
@ -48,7 +48,6 @@
|
||||||
* @property {string} version
|
* @property {string} version
|
||||||
* @property {number} visible_reactions
|
* @property {number} visible_reactions
|
||||||
* @property {string} sso_redirect
|
* @property {string} sso_redirect
|
||||||
* @property {boolean} force_grouped_notifications
|
|
||||||
* @property {string} status_page_url
|
* @property {string} status_page_url
|
||||||
* @property {boolean} system_emoji_font
|
* @property {boolean} system_emoji_font
|
||||||
* @property {string} default_content_type
|
* @property {string} default_content_type
|
||||||
|
@ -102,7 +101,7 @@ if (initialState) {
|
||||||
try {
|
try {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
initialState.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
|
initialState.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
|
||||||
} catch (e) {
|
} catch {
|
||||||
initialState.local_settings = {};
|
initialState.local_settings = {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -152,7 +151,6 @@ export const languages = initialState?.languages;
|
||||||
export const criticalUpdatesPending = initialState?.critical_updates_pending;
|
export const criticalUpdatesPending = initialState?.critical_updates_pending;
|
||||||
export const statusPageUrl = getMeta('status_page_url');
|
export const statusPageUrl = getMeta('status_page_url');
|
||||||
export const sso_redirect = getMeta('sso_redirect');
|
export const sso_redirect = getMeta('sso_redirect');
|
||||||
export const forceGroupedNotifications = getMeta('force_grouped_notifications');
|
|
||||||
|
|
||||||
// Glitch-soc-specific settings
|
// Glitch-soc-specific settings
|
||||||
export const maxFeedHashtags = (initialState && initialState.max_feed_hashtags) || 4;
|
export const maxFeedHashtags = (initialState && initialState.max_feed_hashtags) || 4;
|
||||||
|
|
|
@ -17,7 +17,7 @@ function onProviderError(error: unknown) {
|
||||||
error &&
|
error &&
|
||||||
typeof error === 'object' &&
|
typeof error === 'object' &&
|
||||||
error instanceof Error &&
|
error instanceof Error &&
|
||||||
error.message.match('MISSING_DATA')
|
/MISSING_DATA/.exec(error.message)
|
||||||
) {
|
) {
|
||||||
console.warn(error.message);
|
console.warn(error.message);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ApiNotificationRequestJSON } from 'flavours/glitch/api_types/notifications';
|
||||||
|
|
||||||
|
export interface NotificationRequest
|
||||||
|
extends Omit<ApiNotificationRequestJSON, 'account' | 'notifications_count'> {
|
||||||
|
account_id: string;
|
||||||
|
notifications_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNotificationRequestFromJSON(
|
||||||
|
requestJSON: ApiNotificationRequestJSON,
|
||||||
|
): NotificationRequest {
|
||||||
|
const { account, notifications_count, ...request } = requestJSON;
|
||||||
|
|
||||||
|
return {
|
||||||
|
account_id: account.id,
|
||||||
|
notifications_count: +notifications_count,
|
||||||
|
...request,
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,114 +0,0 @@
|
||||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
|
||||||
|
|
||||||
import { blockAccountSuccess, muteAccountSuccess } from 'flavours/glitch/actions/accounts';
|
|
||||||
import {
|
|
||||||
NOTIFICATION_REQUESTS_EXPAND_REQUEST,
|
|
||||||
NOTIFICATION_REQUESTS_EXPAND_SUCCESS,
|
|
||||||
NOTIFICATION_REQUESTS_EXPAND_FAIL,
|
|
||||||
NOTIFICATION_REQUESTS_FETCH_REQUEST,
|
|
||||||
NOTIFICATION_REQUESTS_FETCH_SUCCESS,
|
|
||||||
NOTIFICATION_REQUESTS_FETCH_FAIL,
|
|
||||||
NOTIFICATION_REQUEST_FETCH_REQUEST,
|
|
||||||
NOTIFICATION_REQUEST_FETCH_SUCCESS,
|
|
||||||
NOTIFICATION_REQUEST_FETCH_FAIL,
|
|
||||||
NOTIFICATION_REQUEST_ACCEPT_REQUEST,
|
|
||||||
NOTIFICATION_REQUEST_DISMISS_REQUEST,
|
|
||||||
NOTIFICATION_REQUESTS_ACCEPT_REQUEST,
|
|
||||||
NOTIFICATION_REQUESTS_DISMISS_REQUEST,
|
|
||||||
NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST,
|
|
||||||
NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS,
|
|
||||||
NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL,
|
|
||||||
NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST,
|
|
||||||
NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS,
|
|
||||||
NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL,
|
|
||||||
} from 'flavours/glitch/actions/notifications';
|
|
||||||
|
|
||||||
import { notificationToMap } from './notifications';
|
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
|
||||||
items: ImmutableList(),
|
|
||||||
isLoading: false,
|
|
||||||
next: null,
|
|
||||||
current: ImmutableMap({
|
|
||||||
isLoading: false,
|
|
||||||
item: null,
|
|
||||||
removed: false,
|
|
||||||
notifications: ImmutableMap({
|
|
||||||
items: ImmutableList(),
|
|
||||||
isLoading: false,
|
|
||||||
next: null,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const normalizeRequest = request => fromJS({
|
|
||||||
...request,
|
|
||||||
account: request.account.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const removeRequest = (state, id) => {
|
|
||||||
if (state.getIn(['current', 'item', 'id']) === id) {
|
|
||||||
state = state.setIn(['current', 'removed'], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.update('items', list => list.filterNot(item => item.get('id') === id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeRequestByAccount = (state, account_id) => {
|
|
||||||
if (state.getIn(['current', 'item', 'account']) === account_id) {
|
|
||||||
state = state.setIn(['current', 'removed'], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.update('items', list => list.filterNot(item => item.get('account') === account_id));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const notificationRequestsReducer = (state = initialState, action) => {
|
|
||||||
switch(action.type) {
|
|
||||||
case NOTIFICATION_REQUESTS_FETCH_SUCCESS:
|
|
||||||
return state.withMutations(map => {
|
|
||||||
map.update('items', list => ImmutableList(action.requests.map(normalizeRequest)).concat(list));
|
|
||||||
map.set('isLoading', false);
|
|
||||||
map.update('next', next => next ?? action.next);
|
|
||||||
});
|
|
||||||
case NOTIFICATION_REQUESTS_EXPAND_SUCCESS:
|
|
||||||
return state.withMutations(map => {
|
|
||||||
map.update('items', list => list.concat(ImmutableList(action.requests.map(normalizeRequest))));
|
|
||||||
map.set('isLoading', false);
|
|
||||||
map.set('next', action.next);
|
|
||||||
});
|
|
||||||
case NOTIFICATION_REQUESTS_EXPAND_REQUEST:
|
|
||||||
case NOTIFICATION_REQUESTS_FETCH_REQUEST:
|
|
||||||
return state.set('isLoading', true);
|
|
||||||
case NOTIFICATION_REQUESTS_EXPAND_FAIL:
|
|
||||||
case NOTIFICATION_REQUESTS_FETCH_FAIL:
|
|
||||||
return state.set('isLoading', false);
|
|
||||||
case NOTIFICATION_REQUEST_ACCEPT_REQUEST:
|
|
||||||
case NOTIFICATION_REQUEST_DISMISS_REQUEST:
|
|
||||||
return removeRequest(state, action.id);
|
|
||||||
case NOTIFICATION_REQUESTS_ACCEPT_REQUEST:
|
|
||||||
case NOTIFICATION_REQUESTS_DISMISS_REQUEST:
|
|
||||||
return action.ids.reduce((state, id) => removeRequest(state, id), state);
|
|
||||||
case blockAccountSuccess.type:
|
|
||||||
return removeRequestByAccount(state, action.payload.relationship.id);
|
|
||||||
case muteAccountSuccess.type:
|
|
||||||
return action.payload.relationship.muting_notifications ? removeRequestByAccount(state, action.payload.relationship.id) : state;
|
|
||||||
case NOTIFICATION_REQUEST_FETCH_REQUEST:
|
|
||||||
return state.set('current', initialState.get('current').set('isLoading', true));
|
|
||||||
case NOTIFICATION_REQUEST_FETCH_SUCCESS:
|
|
||||||
return state.update('current', map => map.set('isLoading', false).set('item', normalizeRequest(action.request)));
|
|
||||||
case NOTIFICATION_REQUEST_FETCH_FAIL:
|
|
||||||
return state.update('current', map => map.set('isLoading', false));
|
|
||||||
case NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST:
|
|
||||||
case NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST:
|
|
||||||
return state.setIn(['current', 'notifications', 'isLoading'], true);
|
|
||||||
case NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS:
|
|
||||||
return state.updateIn(['current', 'notifications'], map => map.set('isLoading', false).update('items', list => ImmutableList(action.notifications.map(notificationToMap)).concat(list)).update('next', next => next ?? action.next));
|
|
||||||
case NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS:
|
|
||||||
return state.updateIn(['current', 'notifications'], map => map.set('isLoading', false).update('items', list => list.concat(ImmutableList(action.notifications.map(notificationToMap)))).set('next', action.next));
|
|
||||||
case NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL:
|
|
||||||
case NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL:
|
|
||||||
return state.setIn(['current', 'notifications', 'isLoading'], false);
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
};
|
|
182
app/javascript/flavours/glitch/reducers/notification_requests.ts
Normal file
182
app/javascript/flavours/glitch/reducers/notification_requests.ts
Normal file
|
@ -0,0 +1,182 @@
|
||||||
|
import { createReducer, isAnyOf } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import {
|
||||||
|
blockAccountSuccess,
|
||||||
|
muteAccountSuccess,
|
||||||
|
} from 'flavours/glitch/actions/accounts';
|
||||||
|
import {
|
||||||
|
fetchNotificationRequests,
|
||||||
|
expandNotificationRequests,
|
||||||
|
fetchNotificationRequest,
|
||||||
|
fetchNotificationsForRequest,
|
||||||
|
expandNotificationsForRequest,
|
||||||
|
acceptNotificationRequest,
|
||||||
|
dismissNotificationRequest,
|
||||||
|
acceptNotificationRequests,
|
||||||
|
dismissNotificationRequests,
|
||||||
|
} from 'flavours/glitch/actions/notification_requests';
|
||||||
|
import type { NotificationRequest } from 'flavours/glitch/models/notification_request';
|
||||||
|
import { createNotificationRequestFromJSON } from 'flavours/glitch/models/notification_request';
|
||||||
|
|
||||||
|
import { notificationToMap } from './notifications';
|
||||||
|
|
||||||
|
interface NotificationsListState {
|
||||||
|
items: unknown[]; // TODO
|
||||||
|
isLoading: boolean;
|
||||||
|
next: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CurrentNotificationRequestState {
|
||||||
|
item: NotificationRequest | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
removed: boolean;
|
||||||
|
notifications: NotificationsListState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationRequestsState {
|
||||||
|
items: NotificationRequest[];
|
||||||
|
isLoading: boolean;
|
||||||
|
next: string | null;
|
||||||
|
current: CurrentNotificationRequestState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: NotificationRequestsState = {
|
||||||
|
items: [],
|
||||||
|
isLoading: false,
|
||||||
|
next: null,
|
||||||
|
current: {
|
||||||
|
item: null,
|
||||||
|
isLoading: false,
|
||||||
|
removed: false,
|
||||||
|
notifications: {
|
||||||
|
isLoading: false,
|
||||||
|
items: [],
|
||||||
|
next: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRequest = (state: NotificationRequestsState, id: string) => {
|
||||||
|
if (state.current.item?.id === id) {
|
||||||
|
state.current.removed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.items = state.items.filter((item) => item.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRequestByAccount = (
|
||||||
|
state: NotificationRequestsState,
|
||||||
|
account_id: string,
|
||||||
|
) => {
|
||||||
|
if (state.current.item?.account_id === account_id) {
|
||||||
|
state.current.removed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.items = state.items.filter((item) => item.account_id !== account_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const notificationRequestsReducer =
|
||||||
|
createReducer<NotificationRequestsState>(initialState, (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchNotificationRequests.fulfilled, (state, action) => {
|
||||||
|
state.items = action.payload.requests
|
||||||
|
.map(createNotificationRequestFromJSON)
|
||||||
|
.concat(state.items);
|
||||||
|
state.isLoading = false;
|
||||||
|
state.next ??= action.payload.next ?? null;
|
||||||
|
})
|
||||||
|
.addCase(expandNotificationRequests.fulfilled, (state, action) => {
|
||||||
|
state.items = state.items.concat(
|
||||||
|
action.payload.requests.map(createNotificationRequestFromJSON),
|
||||||
|
);
|
||||||
|
state.isLoading = false;
|
||||||
|
state.next = action.payload.next ?? null;
|
||||||
|
})
|
||||||
|
.addCase(blockAccountSuccess, (state, action) => {
|
||||||
|
removeRequestByAccount(state, action.payload.relationship.id);
|
||||||
|
})
|
||||||
|
.addCase(muteAccountSuccess, (state, action) => {
|
||||||
|
if (action.payload.relationship.muting_notifications)
|
||||||
|
removeRequestByAccount(state, action.payload.relationship.id);
|
||||||
|
})
|
||||||
|
.addCase(fetchNotificationRequest.pending, (state) => {
|
||||||
|
state.current = { ...initialState.current, isLoading: true };
|
||||||
|
})
|
||||||
|
.addCase(fetchNotificationRequest.rejected, (state) => {
|
||||||
|
state.current.isLoading = false;
|
||||||
|
})
|
||||||
|
.addCase(fetchNotificationRequest.fulfilled, (state, action) => {
|
||||||
|
state.current.isLoading = false;
|
||||||
|
state.current.item = createNotificationRequestFromJSON(action.payload);
|
||||||
|
})
|
||||||
|
.addCase(fetchNotificationsForRequest.fulfilled, (state, action) => {
|
||||||
|
state.current.notifications.isLoading = false;
|
||||||
|
state.current.notifications.items.unshift(
|
||||||
|
...action.payload.notifications.map(notificationToMap),
|
||||||
|
);
|
||||||
|
state.current.notifications.next ??= action.payload.next ?? null;
|
||||||
|
})
|
||||||
|
.addCase(expandNotificationsForRequest.fulfilled, (state, action) => {
|
||||||
|
state.current.notifications.isLoading = false;
|
||||||
|
state.current.notifications.items.push(
|
||||||
|
...action.payload.notifications.map(notificationToMap),
|
||||||
|
);
|
||||||
|
state.current.notifications.next = action.payload.next ?? null;
|
||||||
|
})
|
||||||
|
.addMatcher(
|
||||||
|
isAnyOf(
|
||||||
|
fetchNotificationRequests.pending,
|
||||||
|
expandNotificationRequests.pending,
|
||||||
|
),
|
||||||
|
(state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.addMatcher(
|
||||||
|
isAnyOf(
|
||||||
|
fetchNotificationRequests.rejected,
|
||||||
|
expandNotificationRequests.rejected,
|
||||||
|
),
|
||||||
|
(state) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.addMatcher(
|
||||||
|
isAnyOf(
|
||||||
|
acceptNotificationRequest.pending,
|
||||||
|
dismissNotificationRequest.pending,
|
||||||
|
),
|
||||||
|
(state, action) => {
|
||||||
|
removeRequest(state, action.meta.arg.id);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.addMatcher(
|
||||||
|
isAnyOf(
|
||||||
|
acceptNotificationRequests.pending,
|
||||||
|
dismissNotificationRequests.pending,
|
||||||
|
),
|
||||||
|
(state, action) => {
|
||||||
|
action.meta.arg.ids.forEach((id) => {
|
||||||
|
removeRequest(state, id);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.addMatcher(
|
||||||
|
isAnyOf(
|
||||||
|
fetchNotificationsForRequest.pending,
|
||||||
|
expandNotificationsForRequest.pending,
|
||||||
|
),
|
||||||
|
(state) => {
|
||||||
|
state.current.notifications.isLoading = true;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.addMatcher(
|
||||||
|
isAnyOf(
|
||||||
|
fetchNotificationsForRequest.rejected,
|
||||||
|
expandNotificationsForRequest.rejected,
|
||||||
|
),
|
||||||
|
(state) => {
|
||||||
|
state.current.notifications.isLoading = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
|
@ -55,11 +55,11 @@ const initialState = ImmutableMap({
|
||||||
markNewForDelete: false,
|
markNewForDelete: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const notificationToMap = (notification, markForDelete = false) => ImmutableMap({
|
export const notificationToMap = (notification) => ImmutableMap({
|
||||||
id: notification.id,
|
id: notification.id,
|
||||||
type: notification.type,
|
type: notification.type,
|
||||||
account: notification.account.id,
|
account: notification.account.id,
|
||||||
markedForDelete: markForDelete,
|
markedForDelete: false,
|
||||||
status: notification.status ? notification.status.id : null,
|
status: notification.status ? notification.status.id : null,
|
||||||
report: notification.report ? fromJS(notification.report) : null,
|
report: notification.report ? fromJS(notification.report) : null,
|
||||||
event: notification.event ? fromJS(notification.event) : null,
|
event: notification.event ? fromJS(notification.event) : null,
|
||||||
|
@ -76,7 +76,7 @@ const normalizeNotification = (state, notification, usePendingItems) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (usePendingItems || !state.get('pendingItems').isEmpty()) {
|
if (usePendingItems || !state.get('pendingItems').isEmpty()) {
|
||||||
return state.update('pendingItems', list => list.unshift(notificationToMap(notification, markNewForDelete))).update('unread', unread => unread + 1);
|
return state.update('pendingItems', list => list.unshift(notificationToMap(notification).set('markForDelete', markNewForDelete))).update('unread', unread => unread + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldCountUnreadNotifications(state)) {
|
if (shouldCountUnreadNotifications(state)) {
|
||||||
|
@ -90,7 +90,7 @@ const normalizeNotification = (state, notification, usePendingItems) => {
|
||||||
list = list.take(20);
|
list = list.take(20);
|
||||||
}
|
}
|
||||||
|
|
||||||
return list.unshift(notificationToMap(notification, markNewForDelete));
|
return list.unshift(notificationToMap(notification).set('markForDelete', markNewForDelete));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -104,7 +104,7 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingMore
|
||||||
|
|
||||||
const markNewForDelete = state.get('markNewForDelete');
|
const markNewForDelete = state.get('markNewForDelete');
|
||||||
const lastReadId = state.get('lastReadId');
|
const lastReadId = state.get('lastReadId');
|
||||||
const newItems = ImmutableList(notifications.map((notification) => notificationToMap(notification, markNewForDelete)));
|
const newItems = ImmutableList(notifications.map((notification) => notificationToMap(notification).set('markForDelete', markNewForDelete)));
|
||||||
|
|
||||||
return state.withMutations(mutable => {
|
return state.withMutations(mutable => {
|
||||||
if (!newItems.isEmpty()) {
|
if (!newItems.isEmpty()) {
|
||||||
|
|
|
@ -7,14 +7,16 @@ import { me } from '../initial_state';
|
||||||
|
|
||||||
export { makeGetAccount } from "./accounts";
|
export { makeGetAccount } from "./accounts";
|
||||||
|
|
||||||
const getFilters = (state, { contextType }) => {
|
const getFilters = createSelector([state => state.get('filters'), (_, { contextType }) => contextType], (filters, contextType) => {
|
||||||
if (!contextType) return null;
|
if (!contextType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const serverSideType = toServerSideType(contextType);
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const serverSideType = toServerSideType(contextType);
|
||||||
|
|
||||||
return state.get('filters').filter((filter) => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || filter.get('expires_at') > now));
|
return filters.filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || filter.get('expires_at') > now));
|
||||||
};
|
});
|
||||||
|
|
||||||
export const makeGetStatus = () => {
|
export const makeGetStatus = () => {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
|
@ -74,10 +76,21 @@ const ALERT_DEFAULTS = {
|
||||||
style: false,
|
style: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAlerts = createSelector(state => state.get('alerts'), alerts =>
|
const formatIfNeeded = (intl, message, values) => {
|
||||||
|
if (typeof message === 'object') {
|
||||||
|
return intl.formatMessage(message, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAlerts = createSelector([state => state.get('alerts'), (_, { intl }) => intl], (alerts, intl) =>
|
||||||
alerts.map(item => ({
|
alerts.map(item => ({
|
||||||
...ALERT_DEFAULTS,
|
...ALERT_DEFAULTS,
|
||||||
...item,
|
...item,
|
||||||
|
action: formatIfNeeded(intl, item.action, item.values),
|
||||||
|
title: formatIfNeeded(intl, item.title, item.values),
|
||||||
|
message: formatIfNeeded(intl, item.message, item.values),
|
||||||
})).toArray());
|
})).toArray());
|
||||||
|
|
||||||
export const makeGetNotification = () => createSelector([
|
export const makeGetNotification = () => createSelector([
|
||||||
|
|
|
@ -1,18 +1,27 @@
|
||||||
import { forceGroupedNotifications } from 'flavours/glitch/initial_state';
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import type { RootState } from 'flavours/glitch/store';
|
import type { RootState } from 'flavours/glitch/store';
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||||
// state.settings is not yet typed, so we disable some ESLint checks for those selectors
|
// state.settings is not yet typed, so we disable some ESLint checks for those selectors
|
||||||
export const selectSettingsNotificationsShows = (state: RootState) =>
|
export const selectSettingsNotificationsShows = createSelector(
|
||||||
state.settings.getIn(['notifications', 'shows']).toJS() as Record<
|
[
|
||||||
|
(state) =>
|
||||||
|
state.settings.getIn(['notifications', 'shows']) as Immutable.Map<
|
||||||
string,
|
string,
|
||||||
boolean
|
boolean
|
||||||
>;
|
>,
|
||||||
|
],
|
||||||
|
(shows) => shows.toJS() as Record<string, boolean>,
|
||||||
|
);
|
||||||
|
|
||||||
export const selectSettingsNotificationsExcludedTypes = (state: RootState) =>
|
export const selectSettingsNotificationsExcludedTypes = createSelector(
|
||||||
Object.entries(selectSettingsNotificationsShows(state))
|
[selectSettingsNotificationsShows],
|
||||||
|
(shows) =>
|
||||||
|
Object.entries(shows)
|
||||||
.filter(([_type, enabled]) => !enabled)
|
.filter(([_type, enabled]) => !enabled)
|
||||||
.map(([type, _enabled]) => type);
|
.map(([type, _enabled]) => type),
|
||||||
|
);
|
||||||
|
|
||||||
export const selectSettingsNotificationsQuickFilterShow = (state: RootState) =>
|
export const selectSettingsNotificationsQuickFilterShow = (state: RootState) =>
|
||||||
state.settings.getIn(['notifications', 'quickFilter', 'show']) as boolean;
|
state.settings.getIn(['notifications', 'quickFilter', 'show']) as boolean;
|
||||||
|
@ -26,10 +35,6 @@ export const selectSettingsNotificationsQuickFilterAdvanced = (
|
||||||
) =>
|
) =>
|
||||||
state.settings.getIn(['notifications', 'quickFilter', 'advanced']) as boolean;
|
state.settings.getIn(['notifications', 'quickFilter', 'advanced']) as boolean;
|
||||||
|
|
||||||
export const selectUseGroupedNotifications = (state: RootState) =>
|
|
||||||
forceGroupedNotifications ||
|
|
||||||
(state.settings.getIn(['notifications', 'groupingBeta']) as boolean);
|
|
||||||
|
|
||||||
export const selectSettingsNotificationsShowUnread = (state: RootState) =>
|
export const selectSettingsNotificationsShowUnread = (state: RootState) =>
|
||||||
state.settings.getIn(['notifications', 'showUnread']) as boolean;
|
state.settings.getIn(['notifications', 'showUnread']) as boolean;
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ export default class Settings {
|
||||||
const encodedData = JSON.stringify(data);
|
const encodedData = JSON.stringify(data);
|
||||||
localStorage.setItem(key, encodedData);
|
localStorage.setItem(key, encodedData);
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ export default class Settings {
|
||||||
try {
|
try {
|
||||||
const rawData = localStorage.getItem(key);
|
const rawData = localStorage.getItem(key);
|
||||||
return JSON.parse(rawData);
|
return JSON.parse(rawData);
|
||||||
} catch (e) {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,8 @@ export default class Settings {
|
||||||
const key = this.generateKey(id);
|
const key = this.generateKey(id);
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
} catch (e) {
|
} catch {
|
||||||
|
// ignore if the key is not found
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
|
|
|
@ -30,7 +30,7 @@ function isActionWithmaybeAlertParams(
|
||||||
return isAction(action);
|
return isAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types -- we need to use `{}` here to ensure the dispatch types can be merged
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- we need to use `{}` here to ensure the dispatch types can be merged
|
||||||
export const errorsMiddleware: Middleware<{}, RootState> =
|
export const errorsMiddleware: Middleware<{}, RootState> =
|
||||||
({ dispatch }) =>
|
({ dispatch }) =>
|
||||||
(next) =>
|
(next) =>
|
||||||
|
|
|
@ -51,7 +51,7 @@ const play = (audio: HTMLAudioElement) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const soundsMiddleware = (): Middleware<
|
export const soundsMiddleware = (): Middleware<
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types -- we need to use `{}` here to ensure the dispatch types can be merged
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- we need to use `{}` here to ensure the dispatch types can be merged
|
||||||
{},
|
{},
|
||||||
RootState
|
RootState
|
||||||
> => {
|
> => {
|
||||||
|
|
|
@ -33,8 +33,12 @@ interface AppThunkConfig {
|
||||||
}
|
}
|
||||||
type AppThunkApi = Pick<GetThunkAPI<AppThunkConfig>, 'getState' | 'dispatch'>;
|
type AppThunkApi = Pick<GetThunkAPI<AppThunkConfig>, 'getState' | 'dispatch'>;
|
||||||
|
|
||||||
interface AppThunkOptions {
|
interface AppThunkOptions<Arg> {
|
||||||
useLoadingBar?: boolean;
|
useLoadingBar?: boolean;
|
||||||
|
condition?: (
|
||||||
|
arg: Arg,
|
||||||
|
{ getState }: { getState: AppThunkApi['getState'] },
|
||||||
|
) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createBaseAsyncThunk = createAsyncThunk.withTypes<AppThunkConfig>();
|
const createBaseAsyncThunk = createAsyncThunk.withTypes<AppThunkConfig>();
|
||||||
|
@ -42,7 +46,7 @@ const createBaseAsyncThunk = createAsyncThunk.withTypes<AppThunkConfig>();
|
||||||
export function createThunk<Arg = void, Returned = void>(
|
export function createThunk<Arg = void, Returned = void>(
|
||||||
name: string,
|
name: string,
|
||||||
creator: (arg: Arg, api: AppThunkApi) => Returned | Promise<Returned>,
|
creator: (arg: Arg, api: AppThunkApi) => Returned | Promise<Returned>,
|
||||||
options: AppThunkOptions = {},
|
options: AppThunkOptions<Arg> = {},
|
||||||
) {
|
) {
|
||||||
return createBaseAsyncThunk(
|
return createBaseAsyncThunk(
|
||||||
name,
|
name,
|
||||||
|
@ -70,6 +74,7 @@ export function createThunk<Arg = void, Returned = void>(
|
||||||
if (options.useLoadingBar) return { useLoadingBar: true };
|
if (options.useLoadingBar) return { useLoadingBar: true };
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
|
condition: options.condition,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -96,7 +101,7 @@ type ArgsType = Record<string, unknown> | undefined;
|
||||||
export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
|
export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
|
||||||
name: string,
|
name: string,
|
||||||
loadData: (args: Args) => Promise<LoadDataResult>,
|
loadData: (args: Args) => Promise<LoadDataResult>,
|
||||||
thunkOptions?: AppThunkOptions,
|
thunkOptions?: AppThunkOptions<Args>,
|
||||||
): ReturnType<typeof createThunk<Args, LoadDataResult>>;
|
): ReturnType<typeof createThunk<Args, LoadDataResult>>;
|
||||||
|
|
||||||
// Overload when the `onData` method returns discardLoadDataInPayload, then the payload is empty
|
// Overload when the `onData` method returns discardLoadDataInPayload, then the payload is empty
|
||||||
|
@ -104,17 +109,19 @@ export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
|
||||||
name: string,
|
name: string,
|
||||||
loadData: LoadData<Args, LoadDataResult>,
|
loadData: LoadData<Args, LoadDataResult>,
|
||||||
onDataOrThunkOptions?:
|
onDataOrThunkOptions?:
|
||||||
| AppThunkOptions
|
| AppThunkOptions<Args>
|
||||||
| OnData<Args, LoadDataResult, DiscardLoadData>,
|
| OnData<Args, LoadDataResult, DiscardLoadData>,
|
||||||
thunkOptions?: AppThunkOptions,
|
thunkOptions?: AppThunkOptions<Args>,
|
||||||
): ReturnType<typeof createThunk<Args, void>>;
|
): ReturnType<typeof createThunk<Args, void>>;
|
||||||
|
|
||||||
// Overload when the `onData` method returns nothing, then the mayload is the `onData` result
|
// Overload when the `onData` method returns nothing, then the mayload is the `onData` result
|
||||||
export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
|
export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
|
||||||
name: string,
|
name: string,
|
||||||
loadData: LoadData<Args, LoadDataResult>,
|
loadData: LoadData<Args, LoadDataResult>,
|
||||||
onDataOrThunkOptions?: AppThunkOptions | OnData<Args, LoadDataResult, void>,
|
onDataOrThunkOptions?:
|
||||||
thunkOptions?: AppThunkOptions,
|
| AppThunkOptions<Args>
|
||||||
|
| OnData<Args, LoadDataResult, void>,
|
||||||
|
thunkOptions?: AppThunkOptions<Args>,
|
||||||
): ReturnType<typeof createThunk<Args, LoadDataResult>>;
|
): ReturnType<typeof createThunk<Args, LoadDataResult>>;
|
||||||
|
|
||||||
// Overload when there is an `onData` method returning something
|
// Overload when there is an `onData` method returning something
|
||||||
|
@ -126,9 +133,9 @@ export function createDataLoadingThunk<
|
||||||
name: string,
|
name: string,
|
||||||
loadData: LoadData<Args, LoadDataResult>,
|
loadData: LoadData<Args, LoadDataResult>,
|
||||||
onDataOrThunkOptions?:
|
onDataOrThunkOptions?:
|
||||||
| AppThunkOptions
|
| AppThunkOptions<Args>
|
||||||
| OnData<Args, LoadDataResult, Returned>,
|
| OnData<Args, LoadDataResult, Returned>,
|
||||||
thunkOptions?: AppThunkOptions,
|
thunkOptions?: AppThunkOptions<Args>,
|
||||||
): ReturnType<typeof createThunk<Args, Returned>>;
|
): ReturnType<typeof createThunk<Args, Returned>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -154,6 +161,7 @@ export function createDataLoadingThunk<
|
||||||
* @param maybeThunkOptions
|
* @param maybeThunkOptions
|
||||||
* Additional Mastodon specific options for the thunk. Currently supports:
|
* Additional Mastodon specific options for the thunk. Currently supports:
|
||||||
* - `useLoadingBar` to display a loading bar while this action is pending. Defaults to true.
|
* - `useLoadingBar` to display a loading bar while this action is pending. Defaults to true.
|
||||||
|
* - `condition` is passed to `createAsyncThunk` (https://redux-toolkit.js.org/api/createAsyncThunk#canceling-before-execution)
|
||||||
* @returns The created thunk
|
* @returns The created thunk
|
||||||
*/
|
*/
|
||||||
export function createDataLoadingThunk<
|
export function createDataLoadingThunk<
|
||||||
|
@ -164,12 +172,12 @@ export function createDataLoadingThunk<
|
||||||
name: string,
|
name: string,
|
||||||
loadData: LoadData<Args, LoadDataResult>,
|
loadData: LoadData<Args, LoadDataResult>,
|
||||||
onDataOrThunkOptions?:
|
onDataOrThunkOptions?:
|
||||||
| AppThunkOptions
|
| AppThunkOptions<Args>
|
||||||
| OnData<Args, LoadDataResult, Returned>,
|
| OnData<Args, LoadDataResult, Returned>,
|
||||||
maybeThunkOptions?: AppThunkOptions,
|
maybeThunkOptions?: AppThunkOptions<Args>,
|
||||||
) {
|
) {
|
||||||
let onData: OnData<Args, LoadDataResult, Returned> | undefined;
|
let onData: OnData<Args, LoadDataResult, Returned> | undefined;
|
||||||
let thunkOptions: AppThunkOptions | undefined;
|
let thunkOptions: AppThunkOptions<Args> | undefined;
|
||||||
|
|
||||||
if (typeof onDataOrThunkOptions === 'function') onData = onDataOrThunkOptions;
|
if (typeof onDataOrThunkOptions === 'function') onData = onDataOrThunkOptions;
|
||||||
else if (typeof onDataOrThunkOptions === 'object')
|
else if (typeof onDataOrThunkOptions === 'object')
|
||||||
|
@ -203,6 +211,9 @@ export function createDataLoadingThunk<
|
||||||
return undefined as Returned;
|
return undefined as Returned;
|
||||||
else return result;
|
else return result;
|
||||||
},
|
},
|
||||||
{ useLoadingBar: thunkOptions?.useLoadingBar ?? true },
|
{
|
||||||
|
useLoadingBar: thunkOptions?.useLoadingBar ?? true,
|
||||||
|
condition: thunkOptions?.condition,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@
|
||||||
@import 'widgets';
|
@import 'widgets';
|
||||||
@import 'forms';
|
@import 'forms';
|
||||||
@import 'accounts';
|
@import 'accounts';
|
||||||
@import 'statuses';
|
|
||||||
@import 'components';
|
@import 'components';
|
||||||
@import 'polls';
|
@import 'polls';
|
||||||
@import 'modal';
|
@import 'modal';
|
||||||
|
|
|
@ -1781,15 +1781,29 @@ body > [data-popper-placement] {
|
||||||
.status__prepend {
|
.status__prepend {
|
||||||
padding: 8px 14px; // glitch: reduced padding
|
padding: 8px 14px; // glitch: reduced padding
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
gap: 10px;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: $dark-text-color;
|
color: $dark-text-color;
|
||||||
|
|
||||||
.status__display-name strong {
|
&__icon {
|
||||||
color: $dark-text-color;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
> span {
|
> span {
|
||||||
|
@ -1848,18 +1862,6 @@ body > [data-popper-placement] {
|
||||||
padding: 14px 10px; // glitch: reduced padding
|
padding: 14px 10px; // glitch: reduced padding
|
||||||
border-top: 1px solid var(--background-border-color);
|
border-top: 1px solid var(--background-border-color);
|
||||||
|
|
||||||
&--flex {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
|
|
||||||
.status__content,
|
|
||||||
.detailed-status__meta {
|
|
||||||
flex: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.status__content {
|
.status__content {
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
|
@ -1886,6 +1888,29 @@ body > [data-popper-placement] {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: $dark-text-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&__overlay {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-status {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollable > div:first-child .detailed-status {
|
.scrollable > div:first-child .detailed-status {
|
||||||
|
@ -4096,8 +4121,8 @@ input.glitch-setting-text {
|
||||||
}
|
}
|
||||||
|
|
||||||
&__wrapper {
|
&__wrapper {
|
||||||
background: $white;
|
background: $ui-base-color;
|
||||||
border: 1px solid $ui-secondary-color;
|
border: 1px solid var(--background-border-color);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
|
@ -5049,22 +5074,14 @@ a.status-card {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
||||||
&--minified {
|
&--hidden {
|
||||||
display: block;
|
display: none;
|
||||||
inset-inline-start: 4px;
|
|
||||||
top: 4px;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&--click-thru {
|
&--click-thru {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__overlay {
|
&__overlay {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -5076,19 +5093,20 @@ a.status-card {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: $white;
|
color: $white;
|
||||||
|
line-height: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
background-color: rgba($black, 0.45);
|
background-color: rgba($black, 0.45);
|
||||||
backdrop-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%);
|
backdrop-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 10px 15px;
|
padding: 12px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 4px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__action {
|
&__action {
|
||||||
|
@ -6740,6 +6758,50 @@ a.status-card {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-modal {
|
||||||
|
width: 588px;
|
||||||
|
max-height: 80vh;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--modal-background-color);
|
||||||
|
backdrop-filter: var(--background-filter);
|
||||||
|
border: 1px solid var(--modal-border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
border-bottom: 1px solid var(--modal-border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
padding: 12px 24px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
&__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-paste-text {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hotkey-combination {
|
.hotkey-combination {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -6757,26 +6819,20 @@ a.status-card {
|
||||||
.report-modal,
|
.report-modal,
|
||||||
.actions-modal,
|
.actions-modal,
|
||||||
.compare-history-modal {
|
.compare-history-modal {
|
||||||
background: lighten($ui-secondary-color, 8%);
|
background: var(--background-color);
|
||||||
color: $inverted-text-color;
|
color: $primary-text-color;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--background-border-color);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
width: 480px;
|
width: 480px;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.status__relative-time {
|
@media screen and (max-width: $no-columns-breakpoint) {
|
||||||
order: 2;
|
border-bottom: 0;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__content__spoiler-link {
|
|
||||||
color: lighten($secondary-text-color, 8%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.boost-modal .status-direct {
|
|
||||||
background-color: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.boost-modal__container {
|
.boost-modal__container {
|
||||||
|
@ -6816,6 +6872,7 @@ a.status-card {
|
||||||
.report-modal {
|
.report-modal {
|
||||||
width: 90vw;
|
width: 90vw;
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
|
border: 1px solid var(--background-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-dialog-modal {
|
.report-dialog-modal {
|
||||||
|
@ -7039,7 +7096,7 @@ a.status-card {
|
||||||
|
|
||||||
.report-modal__container {
|
.report-modal__container {
|
||||||
display: flex;
|
display: flex;
|
||||||
border-top: 1px solid $ui-secondary-color;
|
border-top: 1px solid var(--background-border-color);
|
||||||
|
|
||||||
@media screen and (width <= 480px) {
|
@media screen and (width <= 480px) {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
@ -7097,7 +7154,7 @@ a.status-card {
|
||||||
|
|
||||||
.report-modal__comment {
|
.report-modal__comment {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-inline-end: 1px solid $ui-secondary-color;
|
border-inline-end: 1px solid var(--background-border-color);
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
|
@ -7108,7 +7165,7 @@ a.status-card {
|
||||||
|
|
||||||
.setting-text-label {
|
.setting-text-label {
|
||||||
display: block;
|
display: block;
|
||||||
color: $inverted-text-color;
|
color: $secondary-text-color;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
@ -7174,7 +7231,7 @@ a.status-card {
|
||||||
|
|
||||||
li:not(:empty) {
|
li:not(:empty) {
|
||||||
a {
|
a {
|
||||||
color: $inverted-text-color;
|
color: $primary-text-color;
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
@ -7267,7 +7324,7 @@ a.status-card {
|
||||||
|
|
||||||
.compare-history-modal {
|
.compare-history-modal {
|
||||||
.report-modal__target {
|
.report-modal__target {
|
||||||
border-bottom: 1px solid $ui-secondary-color;
|
border-bottom: 1px solid var(--background-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__container {
|
&__container {
|
||||||
|
@ -7277,7 +7334,7 @@ a.status-card {
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__content {
|
.status__content {
|
||||||
color: $inverted-text-color;
|
color: $secondary-text-color;
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
|
|
||||||
|
@ -7339,12 +7396,92 @@ img.modal-warning {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gallery__item__badges {
|
.media-gallery__actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 6px;
|
bottom: 6px;
|
||||||
inset-inline-start: 6px;
|
inset-inline-end: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
&__pill {
|
||||||
|
display: block;
|
||||||
|
color: $white;
|
||||||
|
border: 0;
|
||||||
|
background: rgba($black, 0.65);
|
||||||
|
backdrop-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%);
|
||||||
|
padding: 3px 12px;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-gallery__item__badges {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
inset-inline-start: 8px;
|
||||||
|
display: flex;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gallery__alt__label,
|
.media-gallery__alt__label,
|
||||||
|
@ -7355,18 +7492,13 @@ img.modal-warning {
|
||||||
color: $white;
|
color: $white;
|
||||||
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: 2px 6px;
|
padding: 3px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
line-height: 18px;
|
line-height: 20px;
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-list {
|
.attachment-list {
|
||||||
|
@ -8195,69 +8327,6 @@ noscript {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.embed-modal {
|
|
||||||
width: auto;
|
|
||||||
max-width: 80vw;
|
|
||||||
max-height: 80vh;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
padding: 30px;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.embed-modal__container {
|
|
||||||
padding: 10px;
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.embed-modal__html {
|
|
||||||
outline: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
border: 0;
|
|
||||||
padding: 10px;
|
|
||||||
font-family: $font-monospace, monospace;
|
|
||||||
background: $ui-base-color;
|
|
||||||
color: $primary-text-color;
|
|
||||||
font-size: 14px;
|
|
||||||
margin: 0;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
&::-moz-focus-inner {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-moz-focus-inner,
|
|
||||||
&:focus,
|
|
||||||
&:active {
|
|
||||||
outline: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
background: lighten($ui-base-color, 4%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (width <= 600px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.embed-modal__iframe {
|
|
||||||
width: 400px;
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.moved-account-banner,
|
.moved-account-banner,
|
||||||
.follow-request-banner,
|
.follow-request-banner,
|
||||||
.account-memorial-banner {
|
.account-memorial-banner {
|
||||||
|
@ -10711,6 +10780,7 @@ noscript {
|
||||||
scroll-padding: 16px;
|
scroll-padding: 16px;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
overflow-x: scroll;
|
overflow-x: scroll;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
&__card {
|
&__card {
|
||||||
background: var(--background-color);
|
background: var(--background-color);
|
||||||
|
|
|
@ -12,6 +12,41 @@ code {
|
||||||
margin: 50px auto;
|
margin: 50px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-background-color);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-out-top {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 160px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
var(--surface-background-color),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.indicator-icon {
|
.indicator-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
@ -147,28 +147,6 @@
|
||||||
border-top-color: lighten($ui-base-color, 4%);
|
border-top-color: lighten($ui-base-color, 4%);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Change the background colors of modals
|
|
||||||
.actions-modal,
|
|
||||||
.boost-modal,
|
|
||||||
.confirmation-modal,
|
|
||||||
.mute-modal,
|
|
||||||
.block-modal,
|
|
||||||
.report-modal,
|
|
||||||
.report-dialog-modal,
|
|
||||||
.embed-modal,
|
|
||||||
.error-modal,
|
|
||||||
.onboarding-modal,
|
|
||||||
.compare-history-modal,
|
|
||||||
.report-modal__comment .setting-text__wrapper,
|
|
||||||
.report-modal__comment .setting-text,
|
|
||||||
.announcements,
|
|
||||||
.picture-in-picture__header,
|
|
||||||
.picture-in-picture__footer,
|
|
||||||
.reactions-bar__item {
|
|
||||||
background: $white;
|
|
||||||
border: 1px solid var(--background-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reactions-bar__item:hover,
|
.reactions-bar__item:hover,
|
||||||
.reactions-bar__item:focus,
|
.reactions-bar__item:focus,
|
||||||
.reactions-bar__item:active {
|
.reactions-bar__item:active {
|
||||||
|
@ -198,14 +176,6 @@
|
||||||
color: $white;
|
color: $white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-modal__comment {
|
|
||||||
border-right-color: lighten($ui-base-color, 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-modal__container {
|
|
||||||
border-top-color: lighten($ui-base-color, 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-settings__hashtags .column-select__option {
|
.column-settings__hashtags .column-select__option {
|
||||||
color: $white;
|
color: $white;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,239 +0,0 @@
|
||||||
.activity-stream {
|
|
||||||
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
|
|
||||||
@media screen and (max-width: $no-gap-breakpoint) {
|
|
||||||
margin-bottom: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--headless {
|
|
||||||
border-radius: 0;
|
|
||||||
margin: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
|
|
||||||
.detailed-status,
|
|
||||||
.status {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-component] {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry {
|
|
||||||
background: $ui-base-color;
|
|
||||||
|
|
||||||
.detailed-status,
|
|
||||||
.status,
|
|
||||||
.load-more {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
.detailed-status,
|
|
||||||
.status,
|
|
||||||
.load-more {
|
|
||||||
border-bottom: 0;
|
|
||||||
border-radius: 0 0 4px 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
.detailed-status,
|
|
||||||
.status,
|
|
||||||
.load-more {
|
|
||||||
border-radius: 4px 4px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
.detailed-status,
|
|
||||||
.status,
|
|
||||||
.load-more {
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (width <= 740px) {
|
|
||||||
.detailed-status,
|
|
||||||
.status,
|
|
||||||
.load-more {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--highlighted .entry {
|
|
||||||
background: lighten($ui-base-color, 8%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.logo-button svg {
|
|
||||||
width: 20px;
|
|
||||||
height: auto;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-inline-end: 5px;
|
|
||||||
fill: $primary-text-color;
|
|
||||||
|
|
||||||
@media screen and (max-width: $no-gap-breakpoint) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.embed {
|
|
||||||
.status__content[data-spoiler='folded'] {
|
|
||||||
.e-content {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
p:first-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailed-status {
|
|
||||||
padding: 15px;
|
|
||||||
|
|
||||||
.detailed-status__display-avatar .account__avatar {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
padding: 15px;
|
|
||||||
padding-inline-start: (48px + 15px * 2);
|
|
||||||
min-height: 48px + 2px;
|
|
||||||
|
|
||||||
&__avatar {
|
|
||||||
inset-inline-start: 15px;
|
|
||||||
top: 17px;
|
|
||||||
|
|
||||||
.account__avatar {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__content {
|
|
||||||
padding-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__prepend {
|
|
||||||
padding: 8px 0;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
margin: initial;
|
|
||||||
margin-inline-start: 48px + 15px * 2;
|
|
||||||
padding-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__prepend-icon-wrapper {
|
|
||||||
position: absolute;
|
|
||||||
margin: initial;
|
|
||||||
float: initial;
|
|
||||||
width: auto;
|
|
||||||
inset-inline-start: -32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-gallery,
|
|
||||||
&__action-bar,
|
|
||||||
.video-player {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__action-bar-button {
|
|
||||||
font-size: 18px;
|
|
||||||
width: 23.1429px;
|
|
||||||
height: 23.1429px;
|
|
||||||
line-height: 23.15px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Styling from upstream's WebUI, as public pages use the same layout
|
|
||||||
.embed {
|
|
||||||
.status {
|
|
||||||
.status__info {
|
|
||||||
font-size: 15px;
|
|
||||||
display: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status__relative-time {
|
|
||||||
color: $dark-text-color;
|
|
||||||
float: right;
|
|
||||||
font-size: 14px;
|
|
||||||
width: auto;
|
|
||||||
margin: initial;
|
|
||||||
padding: initial;
|
|
||||||
padding-bottom: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status__visibility-icon {
|
|
||||||
padding: 0 4px;
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
margin-bottom: -2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.status__info .status__display-name {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
padding: 6px 0;
|
|
||||||
padding-right: 25px;
|
|
||||||
margin: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status__avatar {
|
|
||||||
height: 48px;
|
|
||||||
position: absolute;
|
|
||||||
width: 48px;
|
|
||||||
margin: initial;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rtl {
|
|
||||||
.embed {
|
|
||||||
.status {
|
|
||||||
padding-left: 10px;
|
|
||||||
padding-right: 68px;
|
|
||||||
|
|
||||||
.status__info .status__display-name {
|
|
||||||
padding-left: 25px;
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status__relative-time,
|
|
||||||
.status__visibility-icon {
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.status__content__read-more-button,
|
|
||||||
.status__content__translate-button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 20px;
|
|
||||||
color: $highlight-text-color;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
padding-top: 16px;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:active {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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: 1175px;
|
$no-gap-breakpoint: 1207px;
|
||||||
$mobile-breakpoint: 630px;
|
$mobile-breakpoint: 630px;
|
||||||
|
|
||||||
$font-sans-serif: 'mastodon-font-sans-serif' !default;
|
$font-sans-serif: 'mastodon-font-sans-serif' !default;
|
||||||
|
|
|
@ -11,7 +11,7 @@ function _autoUnfoldCW(spoiler_text, skip_unfold_regex) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
regex = new RegExp(skip_unfold_regex.trim(), 'i');
|
regex = new RegExp(skip_unfold_regex.trim(), 'i');
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Bad regex, skip filters
|
// Bad regex, skip filters
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
32
app/javascript/hooks/useRenderSignal.ts
Normal file
32
app/javascript/hooks/useRenderSignal.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
// This hook allows a component to signal that it's done rendering in a way that
|
||||||
|
// can be used by e.g. our embed code to determine correct iframe height
|
||||||
|
|
||||||
|
let renderSignalReceived = false;
|
||||||
|
|
||||||
|
type Callback = () => void;
|
||||||
|
|
||||||
|
let onInitialRender: Callback;
|
||||||
|
|
||||||
|
export const afterInitialRender = (callback: Callback) => {
|
||||||
|
if (renderSignalReceived) {
|
||||||
|
callback();
|
||||||
|
} else {
|
||||||
|
onInitialRender = callback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRenderSignal = () => {
|
||||||
|
return () => {
|
||||||
|
if (renderSignalReceived) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSignalReceived = true;
|
||||||
|
|
||||||
|
if (typeof onInitialRender !== 'undefined') {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
onInitialRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
|
@ -2,7 +2,6 @@ import { debounce } from 'lodash';
|
||||||
|
|
||||||
import type { MarkerJSON } from 'mastodon/api_types/markers';
|
import type { MarkerJSON } from 'mastodon/api_types/markers';
|
||||||
import { getAccessToken } from 'mastodon/initial_state';
|
import { getAccessToken } from 'mastodon/initial_state';
|
||||||
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
|
|
||||||
import type { AppDispatch, RootState } from 'mastodon/store';
|
import type { AppDispatch, RootState } from 'mastodon/store';
|
||||||
import { createAppAsyncThunk } from 'mastodon/store/typed_functions';
|
import { createAppAsyncThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
@ -65,7 +64,7 @@ export const synchronouslySubmitMarkers = createAppAsyncThunk(
|
||||||
client.setRequestHeader('Content-Type', 'application/json');
|
client.setRequestHeader('Content-Type', 'application/json');
|
||||||
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
||||||
client.send(JSON.stringify(params));
|
client.send(JSON.stringify(params));
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Do not make the BeforeUnload handler error out
|
// Do not make the BeforeUnload handler error out
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -76,12 +75,7 @@ interface MarkerParam {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLastNotificationId(state: RootState): string | undefined {
|
function getLastNotificationId(state: RootState): string | undefined {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
return state.notificationGroups.lastReadId;
|
||||||
return selectUseGroupedNotifications(state)
|
|
||||||
? state.notificationGroups.lastReadId
|
|
||||||
: // @ts-expect-error state.notifications is not yet typed
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
||||||
state.getIn(['notifications', 'lastReadId']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildPostMarkersParams = (state: RootState) => {
|
const buildPostMarkersParams = (state: RootState) => {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { createAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
apiClearNotifications,
|
apiClearNotifications,
|
||||||
apiFetchNotifications,
|
apiFetchNotificationGroups,
|
||||||
} from 'mastodon/api/notifications';
|
} from 'mastodon/api/notifications';
|
||||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||||
import type {
|
import type {
|
||||||
|
@ -71,7 +71,7 @@ function dispatchAssociatedRecords(
|
||||||
export const fetchNotifications = createDataLoadingThunk(
|
export const fetchNotifications = createDataLoadingThunk(
|
||||||
'notificationGroups/fetch',
|
'notificationGroups/fetch',
|
||||||
async (_params, { getState }) =>
|
async (_params, { getState }) =>
|
||||||
apiFetchNotifications({ exclude_types: getExcludedTypes(getState()) }),
|
apiFetchNotificationGroups({ exclude_types: getExcludedTypes(getState()) }),
|
||||||
({ notifications, accounts, statuses }, { dispatch }) => {
|
({ notifications, accounts, statuses }, { dispatch }) => {
|
||||||
dispatch(importFetchedAccounts(accounts));
|
dispatch(importFetchedAccounts(accounts));
|
||||||
dispatch(importFetchedStatuses(statuses));
|
dispatch(importFetchedStatuses(statuses));
|
||||||
|
@ -92,7 +92,7 @@ export const fetchNotifications = createDataLoadingThunk(
|
||||||
export const fetchNotificationsGap = createDataLoadingThunk(
|
export const fetchNotificationsGap = createDataLoadingThunk(
|
||||||
'notificationGroups/fetchGap',
|
'notificationGroups/fetchGap',
|
||||||
async (params: { gap: NotificationGap }, { getState }) =>
|
async (params: { gap: NotificationGap }, { getState }) =>
|
||||||
apiFetchNotifications({
|
apiFetchNotificationGroups({
|
||||||
max_id: params.gap.maxId,
|
max_id: params.gap.maxId,
|
||||||
exclude_types: getExcludedTypes(getState()),
|
exclude_types: getExcludedTypes(getState()),
|
||||||
}),
|
}),
|
||||||
|
@ -108,7 +108,7 @@ export const fetchNotificationsGap = createDataLoadingThunk(
|
||||||
export const pollRecentNotifications = createDataLoadingThunk(
|
export const pollRecentNotifications = createDataLoadingThunk(
|
||||||
'notificationGroups/pollRecentNotifications',
|
'notificationGroups/pollRecentNotifications',
|
||||||
async (_params, { getState }) => {
|
async (_params, { getState }) => {
|
||||||
return apiFetchNotifications({
|
return apiFetchNotificationGroups({
|
||||||
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
|
||||||
|
|
234
app/javascript/mastodon/actions/notification_requests.ts
Normal file
234
app/javascript/mastodon/actions/notification_requests.ts
Normal file
|
@ -0,0 +1,234 @@
|
||||||
|
import {
|
||||||
|
apiFetchNotificationRequest,
|
||||||
|
apiFetchNotificationRequests,
|
||||||
|
apiFetchNotifications,
|
||||||
|
apiAcceptNotificationRequest,
|
||||||
|
apiDismissNotificationRequest,
|
||||||
|
apiAcceptNotificationRequests,
|
||||||
|
apiDismissNotificationRequests,
|
||||||
|
} from 'mastodon/api/notifications';
|
||||||
|
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||||
|
import type {
|
||||||
|
ApiNotificationGroupJSON,
|
||||||
|
ApiNotificationJSON,
|
||||||
|
} from 'mastodon/api_types/notifications';
|
||||||
|
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
|
||||||
|
import type { AppDispatch, RootState } from 'mastodon/store';
|
||||||
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||||
|
import { decreasePendingNotificationsCount } from './notification_policies';
|
||||||
|
|
||||||
|
// TODO: refactor with notification_groups
|
||||||
|
function dispatchAssociatedRecords(
|
||||||
|
dispatch: AppDispatch,
|
||||||
|
notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[],
|
||||||
|
) {
|
||||||
|
const fetchedAccounts: ApiAccountJSON[] = [];
|
||||||
|
const fetchedStatuses: ApiStatusJSON[] = [];
|
||||||
|
|
||||||
|
notifications.forEach((notification) => {
|
||||||
|
if (notification.type === 'admin.report') {
|
||||||
|
fetchedAccounts.push(notification.report.target_account);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.type === 'moderation_warning') {
|
||||||
|
fetchedAccounts.push(notification.moderation_warning.target_account);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('status' in notification && notification.status) {
|
||||||
|
fetchedStatuses.push(notification.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fetchedAccounts.length > 0)
|
||||||
|
dispatch(importFetchedAccounts(fetchedAccounts));
|
||||||
|
|
||||||
|
if (fetchedStatuses.length > 0)
|
||||||
|
dispatch(importFetchedStatuses(fetchedStatuses));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchNotificationRequests = createDataLoadingThunk(
|
||||||
|
'notificationRequests/fetch',
|
||||||
|
async (_params, { getState }) => {
|
||||||
|
let sinceId = undefined;
|
||||||
|
|
||||||
|
if (getState().notificationRequests.items.length > 0) {
|
||||||
|
sinceId = getState().notificationRequests.items[0]?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiFetchNotificationRequests({
|
||||||
|
since_id: sinceId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
({ requests, links }, { dispatch }) => {
|
||||||
|
const next = links.refs.find((link) => link.rel === 'next');
|
||||||
|
|
||||||
|
dispatch(importFetchedAccounts(requests.map((request) => request.account)));
|
||||||
|
|
||||||
|
return { requests, next: next?.uri };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: (_params, { getState }) =>
|
||||||
|
!getState().notificationRequests.isLoading,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchNotificationRequest = createDataLoadingThunk(
|
||||||
|
'notificationRequest/fetch',
|
||||||
|
async ({ id }: { id: string }) => apiFetchNotificationRequest(id),
|
||||||
|
{
|
||||||
|
condition: ({ id }, { getState }) =>
|
||||||
|
!(
|
||||||
|
getState().notificationRequests.current.item?.id === id ||
|
||||||
|
getState().notificationRequests.current.isLoading
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const expandNotificationRequests = createDataLoadingThunk(
|
||||||
|
'notificationRequests/expand',
|
||||||
|
async (_, { getState }) => {
|
||||||
|
const nextUrl = getState().notificationRequests.next;
|
||||||
|
if (!nextUrl) throw new Error('missing URL');
|
||||||
|
|
||||||
|
return apiFetchNotificationRequests(undefined, nextUrl);
|
||||||
|
},
|
||||||
|
({ requests, links }, { dispatch }) => {
|
||||||
|
const next = links.refs.find((link) => link.rel === 'next');
|
||||||
|
|
||||||
|
dispatch(importFetchedAccounts(requests.map((request) => request.account)));
|
||||||
|
|
||||||
|
return { requests, next: next?.uri };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: (_, { getState }) =>
|
||||||
|
!!getState().notificationRequests.next &&
|
||||||
|
!getState().notificationRequests.isLoading,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchNotificationsForRequest = createDataLoadingThunk(
|
||||||
|
'notificationRequest/fetchNotifications',
|
||||||
|
async ({ accountId }: { accountId: string }, { getState }) => {
|
||||||
|
const sinceId =
|
||||||
|
// @ts-expect-error current.notifications.items is not yet typed
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||||
|
getState().notificationRequests.current.notifications.items[0]?.get(
|
||||||
|
'id',
|
||||||
|
) as string | undefined;
|
||||||
|
|
||||||
|
return apiFetchNotifications({
|
||||||
|
since_id: sinceId,
|
||||||
|
account_id: accountId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
({ notifications, links }, { dispatch }) => {
|
||||||
|
const next = links.refs.find((link) => link.rel === 'next');
|
||||||
|
|
||||||
|
dispatchAssociatedRecords(dispatch, notifications);
|
||||||
|
|
||||||
|
return { notifications, next: next?.uri };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: ({ accountId }, { getState }) => {
|
||||||
|
const current = getState().notificationRequests.current;
|
||||||
|
return !(
|
||||||
|
current.item?.account_id === accountId &&
|
||||||
|
current.notifications.isLoading
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const expandNotificationsForRequest = createDataLoadingThunk(
|
||||||
|
'notificationRequest/expandNotifications',
|
||||||
|
async (_, { getState }) => {
|
||||||
|
const nextUrl = getState().notificationRequests.current.notifications.next;
|
||||||
|
if (!nextUrl) throw new Error('missing URL');
|
||||||
|
|
||||||
|
return apiFetchNotifications(undefined, nextUrl);
|
||||||
|
},
|
||||||
|
({ notifications, links }, { dispatch }) => {
|
||||||
|
const next = links.refs.find((link) => link.rel === 'next');
|
||||||
|
|
||||||
|
dispatchAssociatedRecords(dispatch, notifications);
|
||||||
|
|
||||||
|
return { notifications, next: next?.uri };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: ({ accountId }: { accountId: string }, { getState }) => {
|
||||||
|
const url = getState().notificationRequests.current.notifications.next;
|
||||||
|
|
||||||
|
return (
|
||||||
|
!!url &&
|
||||||
|
!getState().notificationRequests.current.notifications.isLoading &&
|
||||||
|
getState().notificationRequests.current.item?.account_id === accountId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectNotificationCountForRequest = (state: RootState, id: string) => {
|
||||||
|
const requests = state.notificationRequests.items;
|
||||||
|
const thisRequest = requests.find((request) => request.id === id);
|
||||||
|
return thisRequest ? thisRequest.notifications_count : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const acceptNotificationRequest = createDataLoadingThunk(
|
||||||
|
'notificationRequest/accept',
|
||||||
|
({ id }: { id: string }) => apiAcceptNotificationRequest(id),
|
||||||
|
(_data, { dispatch, getState, discardLoadData, actionArg: { id } }) => {
|
||||||
|
const count = selectNotificationCountForRequest(getState(), id);
|
||||||
|
|
||||||
|
dispatch(decreasePendingNotificationsCount(count));
|
||||||
|
|
||||||
|
// The payload is not used in any functions
|
||||||
|
return discardLoadData;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dismissNotificationRequest = createDataLoadingThunk(
|
||||||
|
'notificationRequest/dismiss',
|
||||||
|
({ id }: { id: string }) => apiDismissNotificationRequest(id),
|
||||||
|
(_data, { dispatch, getState, discardLoadData, actionArg: { id } }) => {
|
||||||
|
const count = selectNotificationCountForRequest(getState(), id);
|
||||||
|
|
||||||
|
dispatch(decreasePendingNotificationsCount(count));
|
||||||
|
|
||||||
|
// The payload is not used in any functions
|
||||||
|
return discardLoadData;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const acceptNotificationRequests = createDataLoadingThunk(
|
||||||
|
'notificationRequests/acceptBulk',
|
||||||
|
({ ids }: { ids: string[] }) => apiAcceptNotificationRequests(ids),
|
||||||
|
(_data, { dispatch, getState, discardLoadData, actionArg: { ids } }) => {
|
||||||
|
const count = ids.reduce(
|
||||||
|
(count, id) => count + selectNotificationCountForRequest(getState(), id),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch(decreasePendingNotificationsCount(count));
|
||||||
|
|
||||||
|
// The payload is not used in any functions
|
||||||
|
return discardLoadData;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dismissNotificationRequests = createDataLoadingThunk(
|
||||||
|
'notificationRequests/dismissBulk',
|
||||||
|
({ ids }: { ids: string[] }) => apiDismissNotificationRequests(ids),
|
||||||
|
(_data, { dispatch, getState, discardLoadData, actionArg: { ids } }) => {
|
||||||
|
const count = ids.reduce(
|
||||||
|
(count, id) => count + selectNotificationCountForRequest(getState(), id),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch(decreasePendingNotificationsCount(count));
|
||||||
|
|
||||||
|
// The payload is not used in any functions
|
||||||
|
return discardLoadData;
|
||||||
|
},
|
||||||
|
);
|
|
@ -18,7 +18,6 @@ import {
|
||||||
importFetchedStatuses,
|
importFetchedStatuses,
|
||||||
} from './importer';
|
} from './importer';
|
||||||
import { submitMarkers } from './markers';
|
import { submitMarkers } from './markers';
|
||||||
import { decreasePendingNotificationsCount } from './notification_policies';
|
|
||||||
import { notificationsUpdate } from "./notifications_typed";
|
import { notificationsUpdate } from "./notifications_typed";
|
||||||
import { register as registerPushNotifications } from './push_notifications';
|
import { register as registerPushNotifications } from './push_notifications';
|
||||||
import { saveSettings } from './settings';
|
import { saveSettings } from './settings';
|
||||||
|
@ -44,26 +43,6 @@ export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
|
||||||
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
|
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
|
||||||
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
|
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
|
||||||
|
|
||||||
export const NOTIFICATION_REQUESTS_FETCH_REQUEST = 'NOTIFICATION_REQUESTS_FETCH_REQUEST';
|
|
||||||
export const NOTIFICATION_REQUESTS_FETCH_SUCCESS = 'NOTIFICATION_REQUESTS_FETCH_SUCCESS';
|
|
||||||
export const NOTIFICATION_REQUESTS_FETCH_FAIL = 'NOTIFICATION_REQUESTS_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATION_REQUESTS_EXPAND_REQUEST = 'NOTIFICATION_REQUESTS_EXPAND_REQUEST';
|
|
||||||
export const NOTIFICATION_REQUESTS_EXPAND_SUCCESS = 'NOTIFICATION_REQUESTS_EXPAND_SUCCESS';
|
|
||||||
export const NOTIFICATION_REQUESTS_EXPAND_FAIL = 'NOTIFICATION_REQUESTS_EXPAND_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATION_REQUEST_FETCH_REQUEST = 'NOTIFICATION_REQUEST_FETCH_REQUEST';
|
|
||||||
export const NOTIFICATION_REQUEST_FETCH_SUCCESS = 'NOTIFICATION_REQUEST_FETCH_SUCCESS';
|
|
||||||
export const NOTIFICATION_REQUEST_FETCH_FAIL = 'NOTIFICATION_REQUEST_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATION_REQUEST_ACCEPT_REQUEST = 'NOTIFICATION_REQUEST_ACCEPT_REQUEST';
|
|
||||||
export const NOTIFICATION_REQUEST_ACCEPT_SUCCESS = 'NOTIFICATION_REQUEST_ACCEPT_SUCCESS';
|
|
||||||
export const NOTIFICATION_REQUEST_ACCEPT_FAIL = 'NOTIFICATION_REQUEST_ACCEPT_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMISS_REQUEST';
|
|
||||||
export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS';
|
|
||||||
export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST';
|
export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST';
|
||||||
export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS';
|
export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS';
|
||||||
export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL';
|
export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL';
|
||||||
|
@ -72,14 +51,6 @@ export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISM
|
||||||
export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS';
|
export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS';
|
||||||
export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL';
|
export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL';
|
||||||
|
|
||||||
export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST';
|
|
||||||
export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS';
|
|
||||||
export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST';
|
|
||||||
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS';
|
|
||||||
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL';
|
|
||||||
|
|
||||||
defineMessages({
|
defineMessages({
|
||||||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
||||||
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
|
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
|
||||||
|
@ -93,12 +64,6 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectNotificationCountForRequest = (state, id) => {
|
|
||||||
const requests = state.getIn(['notificationRequests', 'items']);
|
|
||||||
const thisRequest = requests.find(request => request.get('id') === id);
|
|
||||||
return thisRequest ? thisRequest.get('notifications_count') : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const loadPending = () => ({
|
export const loadPending = () => ({
|
||||||
type: NOTIFICATIONS_LOAD_PENDING,
|
type: NOTIFICATIONS_LOAD_PENDING,
|
||||||
});
|
});
|
||||||
|
@ -343,296 +308,3 @@ export function setBrowserPermission (value) {
|
||||||
value,
|
value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchNotificationRequests = () => (dispatch, getState) => {
|
|
||||||
const params = {};
|
|
||||||
|
|
||||||
if (getState().getIn(['notificationRequests', 'isLoading'])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (getState().getIn(['notificationRequests', 'items'])?.size > 0) {
|
|
||||||
params.since_id = getState().getIn(['notificationRequests', 'items', 0, 'id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(fetchNotificationRequestsRequest());
|
|
||||||
|
|
||||||
api().get('/api/v1/notifications/requests', { params }).then(response => {
|
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
|
||||||
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
|
|
||||||
dispatch(fetchNotificationRequestsSuccess(response.data, next ? next.uri : null));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(fetchNotificationRequestsFail(err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchNotificationRequestsRequest = () => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_FETCH_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationRequestsSuccess = (requests, next) => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_FETCH_SUCCESS,
|
|
||||||
requests,
|
|
||||||
next,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationRequestsFail = error => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_FETCH_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandNotificationRequests = () => (dispatch, getState) => {
|
|
||||||
const url = getState().getIn(['notificationRequests', 'next']);
|
|
||||||
|
|
||||||
if (!url || getState().getIn(['notificationRequests', 'isLoading'])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(expandNotificationRequestsRequest());
|
|
||||||
|
|
||||||
api().get(url).then(response => {
|
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
|
||||||
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
|
|
||||||
dispatch(expandNotificationRequestsSuccess(response.data, next?.uri));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(expandNotificationRequestsFail(err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const expandNotificationRequestsRequest = () => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_EXPAND_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandNotificationRequestsSuccess = (requests, next) => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_EXPAND_SUCCESS,
|
|
||||||
requests,
|
|
||||||
next,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandNotificationRequestsFail = error => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_EXPAND_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationRequest = id => (dispatch, getState) => {
|
|
||||||
const current = getState().getIn(['notificationRequests', 'current']);
|
|
||||||
|
|
||||||
if (current.getIn(['item', 'id']) === id || current.get('isLoading')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(fetchNotificationRequestRequest(id));
|
|
||||||
|
|
||||||
api().get(`/api/v1/notifications/requests/${id}`).then(({ data }) => {
|
|
||||||
dispatch(fetchNotificationRequestSuccess(data));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(fetchNotificationRequestFail(id, err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchNotificationRequestRequest = id => ({
|
|
||||||
type: NOTIFICATION_REQUEST_FETCH_REQUEST,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationRequestSuccess = request => ({
|
|
||||||
type: NOTIFICATION_REQUEST_FETCH_SUCCESS,
|
|
||||||
request,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationRequestFail = (id, error) => ({
|
|
||||||
type: NOTIFICATION_REQUEST_FETCH_FAIL,
|
|
||||||
id,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const acceptNotificationRequest = (id) => (dispatch, getState) => {
|
|
||||||
const count = selectNotificationCountForRequest(getState(), id);
|
|
||||||
dispatch(acceptNotificationRequestRequest(id));
|
|
||||||
|
|
||||||
api().post(`/api/v1/notifications/requests/${id}/accept`).then(() => {
|
|
||||||
dispatch(acceptNotificationRequestSuccess(id));
|
|
||||||
dispatch(decreasePendingNotificationsCount(count));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(acceptNotificationRequestFail(id, err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const acceptNotificationRequestRequest = id => ({
|
|
||||||
type: NOTIFICATION_REQUEST_ACCEPT_REQUEST,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const acceptNotificationRequestSuccess = id => ({
|
|
||||||
type: NOTIFICATION_REQUEST_ACCEPT_SUCCESS,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const acceptNotificationRequestFail = (id, error) => ({
|
|
||||||
type: NOTIFICATION_REQUEST_ACCEPT_FAIL,
|
|
||||||
id,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dismissNotificationRequest = (id) => (dispatch, getState) => {
|
|
||||||
const count = selectNotificationCountForRequest(getState(), id);
|
|
||||||
dispatch(dismissNotificationRequestRequest(id));
|
|
||||||
|
|
||||||
api().post(`/api/v1/notifications/requests/${id}/dismiss`).then(() =>{
|
|
||||||
dispatch(dismissNotificationRequestSuccess(id));
|
|
||||||
dispatch(decreasePendingNotificationsCount(count));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(dismissNotificationRequestFail(id, err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dismissNotificationRequestRequest = id => ({
|
|
||||||
type: NOTIFICATION_REQUEST_DISMISS_REQUEST,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dismissNotificationRequestSuccess = id => ({
|
|
||||||
type: NOTIFICATION_REQUEST_DISMISS_SUCCESS,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dismissNotificationRequestFail = (id, error) => ({
|
|
||||||
type: NOTIFICATION_REQUEST_DISMISS_FAIL,
|
|
||||||
id,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const acceptNotificationRequests = (ids) => (dispatch, getState) => {
|
|
||||||
const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0);
|
|
||||||
dispatch(acceptNotificationRequestsRequest(ids));
|
|
||||||
|
|
||||||
api().post(`/api/v1/notifications/requests/accept`, { id: ids }).then(() => {
|
|
||||||
dispatch(acceptNotificationRequestsSuccess(ids));
|
|
||||||
dispatch(decreasePendingNotificationsCount(count));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(acceptNotificationRequestFail(ids, err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const acceptNotificationRequestsRequest = ids => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_ACCEPT_REQUEST,
|
|
||||||
ids,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const acceptNotificationRequestsSuccess = ids => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_ACCEPT_SUCCESS,
|
|
||||||
ids,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const acceptNotificationRequestsFail = (ids, error) => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_ACCEPT_FAIL,
|
|
||||||
ids,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dismissNotificationRequests = (ids) => (dispatch, getState) => {
|
|
||||||
const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0);
|
|
||||||
dispatch(acceptNotificationRequestsRequest(ids));
|
|
||||||
|
|
||||||
api().post(`/api/v1/notifications/requests/dismiss`, { id: ids }).then(() => {
|
|
||||||
dispatch(dismissNotificationRequestsSuccess(ids));
|
|
||||||
dispatch(decreasePendingNotificationsCount(count));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(dismissNotificationRequestFail(ids, err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dismissNotificationRequestsRequest = ids => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_DISMISS_REQUEST,
|
|
||||||
ids,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dismissNotificationRequestsSuccess = ids => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_DISMISS_SUCCESS,
|
|
||||||
ids,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dismissNotificationRequestsFail = (ids, error) => ({
|
|
||||||
type: NOTIFICATION_REQUESTS_DISMISS_FAIL,
|
|
||||||
ids,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationsForRequest = accountId => (dispatch, getState) => {
|
|
||||||
const current = getState().getIn(['notificationRequests', 'current']);
|
|
||||||
const params = { account_id: accountId };
|
|
||||||
|
|
||||||
if (current.getIn(['item', 'account']) === accountId) {
|
|
||||||
if (current.getIn(['notifications', 'isLoading'])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.getIn(['notifications', 'items'])?.size > 0) {
|
|
||||||
params.since_id = current.getIn(['notifications', 'items', 0, 'id']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(fetchNotificationsForRequestRequest());
|
|
||||||
|
|
||||||
api().get('/api/v1/notifications', { params }).then(response => {
|
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
|
||||||
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
|
|
||||||
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
|
|
||||||
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
|
|
||||||
|
|
||||||
dispatch(fetchNotificationsForRequestSuccess(response.data, next?.uri));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(fetchNotificationsForRequestFail(err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchNotificationsForRequestRequest = () => ({
|
|
||||||
type: NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationsForRequestSuccess = (notifications, next) => ({
|
|
||||||
type: NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS,
|
|
||||||
notifications,
|
|
||||||
next,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationsForRequestFail = (error) => ({
|
|
||||||
type: NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandNotificationsForRequest = () => (dispatch, getState) => {
|
|
||||||
const url = getState().getIn(['notificationRequests', 'current', 'notifications', 'next']);
|
|
||||||
|
|
||||||
if (!url || getState().getIn(['notificationRequests', 'current', 'notifications', 'isLoading'])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(expandNotificationsForRequestRequest());
|
|
||||||
|
|
||||||
api().get(url).then(response => {
|
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
|
||||||
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
|
|
||||||
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
|
|
||||||
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
|
|
||||||
|
|
||||||
dispatch(expandNotificationsForRequestSuccess(response.data, next?.uri));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(expandNotificationsForRequestFail(err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const expandNotificationsForRequestRequest = () => ({
|
|
||||||
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandNotificationsForRequestSuccess = (notifications, next) => ({
|
|
||||||
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS,
|
|
||||||
notifications,
|
|
||||||
next,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandNotificationsForRequestFail = (error) => ({
|
|
||||||
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
|
@ -1,14 +1,10 @@
|
||||||
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
|
|
||||||
import { createAppAsyncThunk } from 'mastodon/store';
|
import { createAppAsyncThunk } from 'mastodon/store';
|
||||||
|
|
||||||
import { fetchNotifications } from './notification_groups';
|
import { fetchNotifications } from './notification_groups';
|
||||||
import { expandNotifications } from './notifications';
|
|
||||||
|
|
||||||
export const initializeNotifications = createAppAsyncThunk(
|
export const initializeNotifications = createAppAsyncThunk(
|
||||||
'notifications/initialize',
|
'notifications/initialize',
|
||||||
(_, { dispatch, getState }) => {
|
(_, { dispatch }) => {
|
||||||
if (selectUseGroupedNotifications(getState()))
|
|
||||||
void dispatch(fetchNotifications());
|
void dispatch(fetchNotifications());
|
||||||
else void dispatch(expandNotifications({}));
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -49,11 +49,13 @@ export function fetchStatusRequest(id, skipLoading) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchStatus(id, forceFetch = false) {
|
export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
|
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
|
||||||
|
|
||||||
|
if (alsoFetchContext) {
|
||||||
dispatch(fetchContext(id));
|
dispatch(fetchContext(id));
|
||||||
|
}
|
||||||
|
|
||||||
if (skipLoading) {
|
if (skipLoading) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
|
|
||||||
|
|
||||||
import { getLocale } from '../locales';
|
import { getLocale } from '../locales';
|
||||||
import { connectStream } from '../stream';
|
import { connectStream } from '../stream';
|
||||||
|
|
||||||
|
@ -105,18 +103,14 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||||
const notificationJSON = JSON.parse(data.payload);
|
const notificationJSON = JSON.parse(data.payload);
|
||||||
dispatch(updateNotifications(notificationJSON, messages, locale));
|
dispatch(updateNotifications(notificationJSON, messages, locale));
|
||||||
// TODO: remove this once the groups feature replaces the previous one
|
// TODO: remove this once the groups feature replaces the previous one
|
||||||
if(selectUseGroupedNotifications(getState())) {
|
|
||||||
dispatch(processNewNotificationForGroups(notificationJSON));
|
dispatch(processNewNotificationForGroups(notificationJSON));
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'notifications_merged':
|
case 'notifications_merged':
|
||||||
const state = getState();
|
const state = getState();
|
||||||
if (state.notifications.top || !state.notifications.mounted)
|
if (state.notifications.top || !state.notifications.mounted)
|
||||||
dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
|
dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
|
||||||
if (selectUseGroupedNotifications(state)) {
|
|
||||||
dispatch(refreshStaleNotificationGroups());
|
dispatch(refreshStaleNotificationGroups());
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'conversation':
|
case 'conversation':
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
|
@ -141,22 +135,16 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Function} dispatch
|
* @param {Function} dispatch
|
||||||
* @param {Function} getState
|
|
||||||
*/
|
*/
|
||||||
async function refreshHomeTimelineAndNotification(dispatch, getState) {
|
async function refreshHomeTimelineAndNotification(dispatch) {
|
||||||
await dispatch(expandHomeTimeline({ maxId: undefined }));
|
await dispatch(expandHomeTimeline({ maxId: undefined }));
|
||||||
|
|
||||||
// TODO: remove this once the groups feature replaces the previous one
|
|
||||||
if(selectUseGroupedNotifications(getState())) {
|
|
||||||
// TODO: polling for merged notifications
|
// TODO: polling for merged notifications
|
||||||
try {
|
try {
|
||||||
await dispatch(pollRecentGroupNotifications());
|
await dispatch(pollRecentGroupNotifications());
|
||||||
} catch (error) {
|
} catch {
|
||||||
// TODO
|
// TODO
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
await dispatch(expandNotifications({}));
|
|
||||||
}
|
|
||||||
|
|
||||||
await dispatch(fetchAnnouncements());
|
await dispatch(fetchAnnouncements());
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,43 @@
|
||||||
import api, { apiRequest, getLinks } from 'mastodon/api';
|
import api, {
|
||||||
import type { ApiNotificationGroupsResultJSON } from 'mastodon/api_types/notifications';
|
apiRequest,
|
||||||
|
getLinks,
|
||||||
|
apiRequestGet,
|
||||||
|
apiRequestPost,
|
||||||
|
} from 'mastodon/api';
|
||||||
|
import type {
|
||||||
|
ApiNotificationGroupsResultJSON,
|
||||||
|
ApiNotificationRequestJSON,
|
||||||
|
ApiNotificationJSON,
|
||||||
|
} from 'mastodon/api_types/notifications';
|
||||||
|
|
||||||
export const apiFetchNotifications = async (params?: {
|
export const apiFetchNotifications = async (
|
||||||
|
params?: {
|
||||||
|
account_id?: string;
|
||||||
|
since_id?: string;
|
||||||
|
},
|
||||||
|
url?: string,
|
||||||
|
) => {
|
||||||
|
const response = await api().request<ApiNotificationJSON[]>({
|
||||||
|
method: 'GET',
|
||||||
|
url: url ?? '/api/v1/notifications',
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: response.data,
|
||||||
|
links: getLinks(response),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiFetchNotificationGroups = async (params?: {
|
||||||
|
url?: string;
|
||||||
exclude_types?: string[];
|
exclude_types?: string[];
|
||||||
max_id?: string;
|
max_id?: string;
|
||||||
since_id?: string;
|
since_id?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const response = await api().request<ApiNotificationGroupsResultJSON>({
|
const response = await api().request<ApiNotificationGroupsResultJSON>({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/api/v2_alpha/notifications',
|
url: '/api/v2/notifications',
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -24,3 +53,43 @@ export const apiFetchNotifications = async (params?: {
|
||||||
|
|
||||||
export const apiClearNotifications = () =>
|
export const apiClearNotifications = () =>
|
||||||
apiRequest<undefined>('POST', 'v1/notifications/clear');
|
apiRequest<undefined>('POST', 'v1/notifications/clear');
|
||||||
|
|
||||||
|
export const apiFetchNotificationRequests = async (
|
||||||
|
params?: {
|
||||||
|
since_id?: string;
|
||||||
|
},
|
||||||
|
url?: string,
|
||||||
|
) => {
|
||||||
|
const response = await api().request<ApiNotificationRequestJSON[]>({
|
||||||
|
method: 'GET',
|
||||||
|
url: url ?? '/api/v1/notifications/requests',
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
requests: response.data,
|
||||||
|
links: getLinks(response),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiFetchNotificationRequest = async (id: string) => {
|
||||||
|
return apiRequestGet<ApiNotificationRequestJSON>(
|
||||||
|
`v1/notifications/requests/${id}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiAcceptNotificationRequest = async (id: string) => {
|
||||||
|
return apiRequestPost(`v1/notifications/requests/${id}/accept`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiDismissNotificationRequest = async (id: string) => {
|
||||||
|
return apiRequestPost(`v1/notifications/requests/${id}/dismiss`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiAcceptNotificationRequests = async (id: string[]) => {
|
||||||
|
return apiRequestPost('v1/notifications/requests/accept', { id });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiDismissNotificationRequests = async (id: string[]) => {
|
||||||
|
return apiRequestPost('v1/notifications/dismiss/dismiss', { id });
|
||||||
|
};
|
||||||
|
|
|
@ -149,3 +149,12 @@ export interface ApiNotificationGroupsResultJSON {
|
||||||
statuses: ApiStatusJSON[];
|
statuses: ApiStatusJSON[];
|
||||||
notification_groups: ApiNotificationGroupJSON[];
|
notification_groups: ApiNotificationGroupJSON[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiNotificationRequestJSON {
|
||||||
|
id: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
notifications_count: string;
|
||||||
|
account: ApiAccountJSON;
|
||||||
|
last_status?: ApiStatusJSON;
|
||||||
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ export function start() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Rails.start();
|
Rails.start();
|
||||||
} catch (e) {
|
} catch {
|
||||||
// If called twice
|
// If called twice
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
90
app/javascript/mastodon/components/copy_paste_text.tsx
Normal file
90
app/javascript/mastodon/components/copy_paste_text.tsx
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import { useRef, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||||
|
import { useTimeout } from 'mastodon/../hooks/useTimeout';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
|
||||||
|
export const CopyPasteText: React.FC<{ value: string }> = ({ value }) => {
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [setAnimationTimeout] = useTimeout();
|
||||||
|
|
||||||
|
const handleInputClick = useCallback(() => {
|
||||||
|
setCopied(false);
|
||||||
|
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
inputRef.current.select();
|
||||||
|
inputRef.current.setSelectionRange(0, value.length);
|
||||||
|
}
|
||||||
|
}, [setCopied, value]);
|
||||||
|
|
||||||
|
const handleButtonClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void navigator.clipboard.writeText(value);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
setCopied(true);
|
||||||
|
setAnimationTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 700);
|
||||||
|
},
|
||||||
|
[setCopied, setAnimationTimeout, value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyUp = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key !== ' ') return;
|
||||||
|
void navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
setAnimationTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 700);
|
||||||
|
},
|
||||||
|
[setCopied, setAnimationTimeout, value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
setFocused(true);
|
||||||
|
}, [setFocused]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
setFocused(false);
|
||||||
|
}, [setFocused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames('copy-paste-text', { copied, focused })}
|
||||||
|
tabIndex={0}
|
||||||
|
role='button'
|
||||||
|
onClick={handleInputClick}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={value}
|
||||||
|
ref={inputRef}
|
||||||
|
onClick={handleInputClick}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button className='button' onClick={handleButtonClick}>
|
||||||
|
<Icon id='copy' icon={ContentCopyIcon} />{' '}
|
||||||
|
{copied ? (
|
||||||
|
<FormattedMessage id='copypaste.copied' defaultMessage='Copied' />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage
|
||||||
|
id='copypaste.copy_to_clipboard'
|
||||||
|
defaultMessage='Copy to clipboard'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue