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:
Jeremy Kescher 2024-09-20 11:53:07 +02:00
commit 655d28f6a7
No known key found for this signature in database
GPG key ID: 80A419A7A613DFA4
494 changed files with 7028 additions and 5651 deletions

View file

@ -333,7 +333,7 @@ module.exports = defineConfig({
],
parserOptions: {
project: true,
projectService: true,
tsconfigRootDir: __dirname,
},

3
.github/codecov.yml vendored
View file

@ -1,4 +1,3 @@
annotations: false
comment: false # Do not leave PR comments
coverage:
status:
@ -10,3 +9,5 @@ coverage:
default:
# GitHub status check is not blocking
informational: true
github_checks:
annotations: false

View file

@ -7,8 +7,13 @@ RSpec/Focus: # Require full spec run on CI
Exclude: []
Rails/Output: # Remove any `puts` debugging
inherit_mode:
merge:
- Include
Enabled: true
Exclude: []
Include:
- spec/**/*.rb
Rails/FindEach: # Using `each` could impact performance, use `find_each`
Enabled: true

View file

@ -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."
## [Unreleased] - Unreleased
## [v4.3.0-beta.2+cat.1.0.0] - 2024-09-20
- 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

View file

@ -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)\
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
- **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.\
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.\
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:
- 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_alpha/notifications/:group_key`: https://docs.joinmastodon.org/methods/notifications_alpha/#get-notification-group
- `POST /api/v2_alpha/notifications/:group_key/dimsiss`: https://docs.joinmastodon.org/methods/notifications_alpha/#dismiss-group
- `GET /api/v2_alpha/notifications/:unread_count`: https://docs.joinmastodon.org/methods/notifications_alpha/#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, and #31541 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\
- `GET /api/v2/notifications`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-grouped
- `GET /api/v2/notifications/:group_key`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-notification-group
- `GET /api/v2/notifications/:group_key/accounts`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts
- `POST /api/v2/notifications/:group_key/dimsiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group
- `GET /api/v2/notifications/:unread_count`: https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count
- **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, and #31723 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\
The old “Block notifications from non-followers”, “Block notifications from people you don't follow” and “Block direct messages from people you don't follow” notification settings have been replaced by a new set of settings found directly in the notification column.\
You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\
Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\
@ -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)\
You can now see public posts mentioning currently-trending articles from people who have opted into discovery features.\
This adds a new REST API endpoint: https://docs.joinmastodon.org/methods/timelines/#link
- **Add author highlight for news articles whose authors are on the fediverse** (#30398, #30670, #30521, 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.\
Articles hosted outside the fediverse can indicate a fediverse author with a meta tag:
```html
<meta name="fediverse:creator" content="username@domain" />
```
On the API side, this is represented by a new `authors` attribute to the `PreviewCard` entity: https://docs.joinmastodon.org/entities/PreviewCard/#authors\
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)\
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).
- **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.
- **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 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)\
@ -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 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
- 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 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)
@ -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 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 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 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.\
This requires libvips 8.13 or newer, and needs to be enabled with `MASTODON_USE_LIBVIPS=true`.\
This is enabled by default in the official Docker images, and is intended to completely replace ImageMagick in the future.
- Add validations to `Web::PushSubscription` (#30540 and #30542 by @ThisIsMissEm)
- Add anchors to each authorized application in `/oauth/authorized_applications` (#31677 by @fowl2)
- Add active animation to header settings button (#30221, #30307, and #30388 by @daudix)
- Add OpenTelemetry instrumentation (#30130, #30322, #30353, and #30350 by @julianocosta89, @renchap, and @robbkidd)\
See https://docs.joinmastodon.org/admin/config/#otel for documentation
- Add API to get multiple accounts and statuses (#27871 and #30465 by @ClearlyClaire)\
This adds `GET /api/v1/accounts` and `GET /api/v1/statuses` to the REST API, see https://docs.joinmastodon.org/methods/accounts/#index and https://docs.joinmastodon.org/methods/statuses/#index
- 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 RFC8414 OAuth 2.0 server metadata (#29191 by @ThisIsMissEm)
- Add loading indicator and empty result message to advanced interface search (#30085 by @ClearlyClaire)
- Add `profile` OAuth 2.0 scope, allowing more limited access to user data (#29087 and #30357 by @ThisIsMissEm)
- Add global Regexp timeout (#31928 by @ClearlyClaire)
- Add the role ID to the badge component (#29707 by @renchap)
- Add diagnostic message for failure during CLI search deploy (#29462 by @mjankowski)
- Add pagination `Link` headers on API accounts/statuses when pinned true (#29442 by @mjankowski)
@ -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)\
This changes all the interface icons from FontAwesome to Material Symbols for a more modern look, consistent with the official Mastodon Android app.\
In addition, better care is given to pixel alignment, and icon variants are used to better highlight active/inactive state.
- **Change design of compose form in web UI** (#28119, #29059, #29248, #29372, #29384, #29417, #29456, #29406, #29651, 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.\
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.\
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 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)\
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)\
@ -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.\
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 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.\
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.
- 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)\
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 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 design of people tab on explore in web UI (#30059 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 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)
@ -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 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 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 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)
@ -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 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 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 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 “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)

View file

@ -111,7 +111,7 @@ group :opentelemetry do
gem 'opentelemetry-instrumentation-http', '~> 0.23.2', 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-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-rails', '~> 0.31.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false

View file

@ -100,17 +100,17 @@ GEM
attr_required (1.0.2)
awrence (1.2.1)
aws-eventstream (1.3.0)
aws-partitions (1.970.0)
aws-sdk-core (3.203.0)
aws-partitions (1.974.0)
aws-sdk-core (3.205.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.89.0)
aws-sdk-core (~> 3, >= 3.203.0)
aws-sdk-kms (1.91.0)
aws-sdk-core (~> 3, >= 3.205.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.160.0)
aws-sdk-core (~> 3, >= 3.203.0)
aws-sdk-s3 (1.162.0)
aws-sdk-core (~> 3, >= 3.205.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.9.1)
@ -331,7 +331,7 @@ GEM
httplog (1.7.0)
rack (>= 2.0)
rainbow (>= 2.0.0)
i18n (1.14.5)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.14)
activesupport (>= 4.0.2)
@ -458,7 +458,7 @@ GEM
nokogiri (1.16.7)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
oj (3.16.5)
oj (3.16.6)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.2)
@ -472,9 +472,9 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth-saml (2.1.0)
omniauth (~> 2.0)
ruby-saml (~> 1.12)
omniauth-saml (2.2.1)
omniauth (~> 2.1)
ruby-saml (~> 1.17)
omniauth_openid_connect (0.6.1)
omniauth (>= 1.9, < 3)
openid_connect (~> 1.1)
@ -502,8 +502,8 @@ GEM
opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.2)
opentelemetry-semantic_conventions
opentelemetry-helpers-sql-obfuscation (0.1.0)
opentelemetry-common (~> 0.20)
opentelemetry-helpers-sql-obfuscation (0.2.0)
opentelemetry-common (~> 0.21)
opentelemetry-instrumentation-action_mailer (0.1.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.1)
@ -528,8 +528,9 @@ GEM
opentelemetry-instrumentation-active_support (0.6.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-base (0.22.3)
opentelemetry-instrumentation-base (0.22.6)
opentelemetry-api (~> 1.0)
opentelemetry-common (~> 0.21)
opentelemetry-registry (~> 0.1)
opentelemetry-instrumentation-concurrent_ruby (0.21.4)
opentelemetry-api (~> 1.0)
@ -549,7 +550,7 @@ GEM
opentelemetry-instrumentation-net_http (0.22.7)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-pg (0.28.0)
opentelemetry-instrumentation-pg (0.29.0)
opentelemetry-api (~> 1.0)
opentelemetry-helpers-sql-obfuscation
opentelemetry-instrumentation-base (~> 0.22.1)
@ -601,7 +602,7 @@ GEM
actionmailer (>= 3)
net-smtp
premailer (~> 1.7, >= 1.7.9)
propshaft (0.9.1)
propshaft (1.0.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
@ -691,7 +692,7 @@ GEM
redlock (1.3.2)
redis (>= 3.0.0, < 6.0)
regexp_parser (2.9.2)
reline (0.5.9)
reline (0.5.10)
io-console (~> 0.5)
request_store (1.6.0)
rack (>= 1.4)
@ -763,7 +764,7 @@ GEM
rubocop-rspec (~> 3, >= 3.0.1)
ruby-prof (1.7.0)
ruby-progressbar (1.13.0)
ruby-saml (1.16.0)
ruby-saml (1.17.0)
nokogiri (>= 1.13.10)
rexml
ruby-vips (2.2.2)
@ -991,7 +992,7 @@ DEPENDENCIES
opentelemetry-instrumentation-http (~> 0.23.2)
opentelemetry-instrumentation-http_client (~> 0.22.3)
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-rails (~> 0.31.0)
opentelemetry-instrumentation-redis (~> 0.25.3)

View file

@ -1,6 +1,6 @@
# 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 :require_user!
before_action :set_notifications!
@ -33,11 +33,11 @@ class Api::V2Alpha::Notifications::AccountsController < Api::BaseController
end
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
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
def pagination_collection

View file

@ -1,6 +1,6 @@
# 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! :write, :'write:notifications' }, only: [:clear, :dismiss]
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
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 }
span.add_attributes(
@ -64,7 +64,7 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
private
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(
limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
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
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])
end
end
@ -101,11 +101,11 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
end
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
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
def pagination_collection

View file

@ -8,6 +8,16 @@ module WebAppControllerConcern
before_action :redirect_unauthenticated_to_permalinks!
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
def skip_csrf_meta_tags?

View file

@ -4,7 +4,6 @@ class Redirect::BaseController < ApplicationController
vary_by 'Accept-Language'
before_action :set_resource
before_action :set_app_body_class
def show
@redirect_path = ActivityPub::TagManager.instance.url_for(@resource)
@ -14,10 +13,6 @@ class Redirect::BaseController < ApplicationController
private
def set_app_body_class
@body_classes = 'app-body'
end
def set_resource
raise NotImplementedError
end

View file

@ -2,14 +2,30 @@
class Settings::VerificationsController < Settings::BaseController
before_action :set_account
before_action :set_verified_links
def show
@verified_links = @account.fields.select(&:verified?)
def show; end
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
private
def account_params
params.require(:account).permit(:attribution_domains_as_text)
end
def set_account
@account = current_account
end
def set_verified_links
@verified_links = @account.fields.select(&:verified?)
end
end

View file

@ -11,7 +11,6 @@ class StatusesController < ApplicationController
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_status
before_action :redirect_to_original, only: :show
before_action :set_body_classes, only: :embed
after_action :set_link_headers
@ -51,10 +50,6 @@ class StatusesController < ApplicationController
private
def set_body_classes
@body_classes = 'with-modals'
end
def set_link_headers
response.headers['Link'] = LinkHeader.new(
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]

View file

@ -19,14 +19,6 @@ module AccountsHelper
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)
Setting.hide_followers_count || account.user&.settings&.[]('hide_followers_count')
end

View file

@ -42,6 +42,7 @@ module ContextHelper
'cipherText' => 'toot:cipherText',
},
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
}.freeze
def full_context

View file

@ -57,26 +57,6 @@ module MediaComponentHelper
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
def serialize_media_attachment(attachment)
@ -86,22 +66,6 @@ module MediaComponentHelper
)
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)
if !account.nil? && account.id == status.account_id
status.sensitive

View file

@ -4,6 +4,13 @@ module StatusesHelper
EMBEDDED_CONTROLLER = 'statuses'
EMBEDDED_ACTION = 'embed'
VISIBLITY_ICONS = {
public: 'globe',
unlisted: 'lock_open',
private: 'lock',
direct: 'alternate_email',
}.freeze
def nothing_here(extra_classes = '')
content_tag(:div, class: "nothing-here #{extra_classes}") do
t('accounts.nothing_here')
@ -57,17 +64,8 @@ module StatusesHelper
embedded_view? ? '_blank' : nil
end
def fa_visibility_icon(status)
case status.visibility
when 'public'
material_symbol 'globe'
when 'unlisted'
material_symbol 'lock_open'
when 'private'
material_symbol 'lock'
when 'direct'
material_symbol 'alternate_email'
end
def visibility_icon(status)
VISIBLITY_ICONS[status.visibility.to_sym]
end
def embedded_view?

View 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,
},
'*',
);
});
});

View file

@ -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() {
const { messages: localeData } = getLocale();

View file

@ -2,7 +2,6 @@ import { debounce } from 'lodash';
import type { MarkerJSON } from 'flavours/glitch/api_types/markers';
import { getAccessToken } from 'flavours/glitch/initial_state';
import { selectUseGroupedNotifications } from 'flavours/glitch/selectors/settings';
import type { AppDispatch, RootState } from 'flavours/glitch/store';
import { createAppAsyncThunk } from 'flavours/glitch/store/typed_functions';
@ -65,7 +64,7 @@ export const synchronouslySubmitMarkers = createAppAsyncThunk(
client.setRequestHeader('Content-Type', 'application/json');
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
client.send(JSON.stringify(params));
} catch (e) {
} catch {
// Do not make the BeforeUnload handler error out
}
},
@ -76,12 +75,7 @@ interface MarkerParam {
}
function getLastNotificationId(state: RootState): string | undefined {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
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']);
return state.notificationGroups.lastReadId;
}
const buildPostMarkersParams = (state: RootState) => {

View file

@ -2,7 +2,7 @@ import { createAction } from '@reduxjs/toolkit';
import {
apiClearNotifications,
apiFetchNotifications,
apiFetchNotificationGroups,
} from 'flavours/glitch/api/notifications';
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
import type {
@ -71,7 +71,7 @@ function dispatchAssociatedRecords(
export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch',
async (_params, { getState }) =>
apiFetchNotifications({ exclude_types: getExcludedTypes(getState()) }),
apiFetchNotificationGroups({ exclude_types: getExcludedTypes(getState()) }),
({ notifications, accounts, statuses }, { dispatch }) => {
dispatch(importFetchedAccounts(accounts));
dispatch(importFetchedStatuses(statuses));
@ -92,7 +92,7 @@ export const fetchNotifications = createDataLoadingThunk(
export const fetchNotificationsGap = createDataLoadingThunk(
'notificationGroups/fetchGap',
async (params: { gap: NotificationGap }, { getState }) =>
apiFetchNotifications({
apiFetchNotificationGroups({
max_id: params.gap.maxId,
exclude_types: getExcludedTypes(getState()),
}),
@ -108,7 +108,7 @@ export const fetchNotificationsGap = createDataLoadingThunk(
export const pollRecentNotifications = createDataLoadingThunk(
'notificationGroups/pollRecentNotifications',
async (_params, { getState }) => {
return apiFetchNotifications({
return apiFetchNotificationGroups({
max_id: undefined,
exclude_types: getExcludedTypes(getState()),
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones

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

View file

@ -18,7 +18,6 @@ import {
importFetchedStatuses,
} from './importer';
import { submitMarkers } from './markers';
import { decreasePendingNotificationsCount } from './notification_policies';
import { notificationsUpdate } from "./notifications_typed";
import { register as registerPushNotifications } from './push_notifications';
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_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_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS';
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_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({
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 = () => ({
type: NOTIFICATIONS_LOAD_PENDING,
});
@ -432,296 +397,3 @@ export function setBrowserPermission (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,
});

View file

@ -1,14 +1,10 @@
import { selectUseGroupedNotifications } from 'flavours/glitch/selectors/settings';
import { createAppAsyncThunk } from 'flavours/glitch/store';
import { fetchNotifications } from './notification_groups';
import { expandNotifications } from './notifications';
export const initializeNotifications = createAppAsyncThunk(
'notifications/initialize',
(_, { dispatch, getState }) => {
if (selectUseGroupedNotifications(getState()))
void dispatch(fetchNotifications());
else void dispatch(expandNotifications({}));
(_, { dispatch }) => {
void dispatch(fetchNotifications());
},
);

View file

@ -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) => {
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
dispatch(fetchContext(id));
if (alsoFetchContext) {
dispatch(fetchContext(id));
}
if (skipLoading) {
return;

View file

@ -1,7 +1,5 @@
// @ts-check
import { selectUseGroupedNotifications } from 'flavours/glitch/selectors/settings';
import { getLocale } from '../locales';
import { connectStream } from '../stream';
@ -105,18 +103,14 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
const notificationJSON = JSON.parse(data.payload);
dispatch(updateNotifications(notificationJSON, messages, locale));
// TODO: remove this once the groups feature replaces the previous one
if(selectUseGroupedNotifications(getState())) {
dispatch(processNewNotificationForGroups(notificationJSON));
}
dispatch(processNewNotificationForGroups(notificationJSON));
break;
}
case 'notifications_merged':
const state = getState();
if (state.notifications.top || !state.notifications.mounted)
dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
if (selectUseGroupedNotifications(state)) {
dispatch(refreshStaleNotificationGroups());
}
dispatch(refreshStaleNotificationGroups());
break;
case 'conversation':
// @ts-expect-error
@ -141,21 +135,15 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
/**
* @param {Function} dispatch
* @param {Function} getState
*/
async function refreshHomeTimelineAndNotification(dispatch, getState) {
async function refreshHomeTimelineAndNotification(dispatch) {
await dispatch(expandHomeTimeline({ maxId: undefined }));
// TODO: remove this once the groups feature replaces the previous one
if(selectUseGroupedNotifications(getState())) {
// TODO: polling for merged notifications
try {
await dispatch(pollRecentGroupNotifications());
} catch (error) {
// TODO
}
} else {
await dispatch(expandNotifications({}));
// TODO: polling for merged notifications
try {
await dispatch(pollRecentGroupNotifications());
} catch {
// TODO
}
await dispatch(fetchAnnouncements());

View file

@ -1,14 +1,43 @@
import api, { apiRequest, getLinks } from 'flavours/glitch/api';
import type { ApiNotificationGroupsResultJSON } from 'flavours/glitch/api_types/notifications';
import api, {
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[];
max_id?: string;
since_id?: string;
}) => {
const response = await api().request<ApiNotificationGroupsResultJSON>({
method: 'GET',
url: '/api/v2_alpha/notifications',
url: '/api/v2/notifications',
params,
});
@ -24,3 +53,43 @@ export const apiFetchNotifications = async (params?: {
export const apiClearNotifications = () =>
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 });
};

View file

@ -151,3 +151,12 @@ export interface ApiNotificationGroupsResultJSON {
statuses: ApiStatusJSON[];
notification_groups: ApiNotificationGroupJSON[];
}
export interface ApiNotificationRequestJSON {
id: string;
created_at: string;
updated_at: string;
notifications_count: string;
account: ApiAccountJSON;
last_status?: ApiStatusJSON;
}

View file

@ -5,7 +5,7 @@ export function start() {
try {
Rails.start();
} catch (e) {
} catch {
// If called twice
}
}

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

View file

@ -60,8 +60,8 @@ export default class ErrorBoundary extends PureComponent {
try {
textarea.select();
document.execCommand('copy');
} catch (e) {
} catch {
// do nothing
} finally {
document.body.removeChild(textarea);
}

View file

@ -7,6 +7,13 @@ export const WordmarkLogo: React.FC = () => (
</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 = () => (
<img src={logo} alt='Mastodon' className='logo logo--icon' />
);

View file

@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
@ -10,17 +10,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { debounce } from 'lodash';
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import { Blurhash } from 'flavours/glitch/components/blurhash';
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 {
static propTypes = {
@ -220,7 +213,6 @@ class MediaGallery extends PureComponent {
lang: PropTypes.string,
size: PropTypes.object,
onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
visible: PropTypes.bool,
@ -309,7 +301,7 @@ class MediaGallery extends PureComponent {
}
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 size = media.size;
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
@ -320,7 +312,7 @@ class MediaGallery extends PureComponent {
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
style.aspectRatio = `${this.props.media.getIn([0, 'meta', 'small', 'aspect'])}`;
@ -343,9 +335,7 @@ class MediaGallery extends PureComponent {
</span>
</button>
);
} else if (visible) {
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' iconComponent={VisibilityOffIcon} overlay onClick={this.handleOpen} ariaHidden />;
} else {
} else if (!visible) {
spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>
@ -358,15 +348,23 @@ class MediaGallery extends PureComponent {
return (
<div className={computedClass} style={style} ref={this.handleRef}>
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
{spoilerButton}
</div>
{(!visible || uncached) && (
<div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
{spoilerButton}
</div>
)}
{children}
{(visible && !uncached) && (
<div className='media-gallery__actions'>
<button className='media-gallery__actions__pill' onClick={this.handleOpen}><FormattedMessage id='media_gallery.hide' defaultMessage='Hide' /></button>
</div>
)}
</div>
);
}
}
export default injectIntl(MediaGallery);
export default MediaGallery;

View file

@ -2,14 +2,12 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { IconLogo } from 'flavours/glitch/components/logo';
import { AuthorLink } from 'flavours/glitch/features/explore/components/author_link';
export const MoreFromAuthor = ({ accountId }) => (
<div className='more-from-author'>
<svg viewBox='0 0 79 79' className='logo logo--icon' role='img'>
<use xlinkHref='#logo-symbol-icon' />
</svg>
<IconLogo />
<FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <AuthorLink accountId={accountId} /> }} />
</div>
);

View file

@ -59,7 +59,7 @@ const messages = defineMessages({
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
pin: { id: 'status.pin', defaultMessage: 'Pin on 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_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}' },

View file

@ -163,11 +163,13 @@ export default class StatusPrepend extends PureComponent {
return !type ? null : (
<aside className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend' : 'notification__message'}>
<Icon
className={`status__prepend-icon ${type === 'favourite' ? 'star-icon' : ''}`}
id={iconId}
icon={iconComponent}
/>
<div className='status__prepend__icon'>
<Icon
className={type === 'favourite' ? 'star-icon' : null}
id={iconId}
icon={iconComponent}
/>
</div>
<Message />
{children}
</aside>

View file

@ -21,9 +21,9 @@ export default class StatusReactions extends ImmutablePureComponent {
statusId: PropTypes.string.isRequired,
reactions: ImmutablePropTypes.list.isRequired,
numVisible: PropTypes.number,
addReaction: PropTypes.func.isRequired,
addReaction: PropTypes.func,
canReact: PropTypes.bool.isRequired,
removeReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func,
};
willEnter() {
@ -78,8 +78,8 @@ class Reaction extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string,
reaction: ImmutablePropTypes.map.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
addReaction: PropTypes.func,
removeReaction: PropTypes.func,
canReact: PropTypes.bool.isRequired,
style: PropTypes.object,
};
@ -91,9 +91,9 @@ class Reaction extends ImmutablePureComponent {
handleClick = () => {
const { reaction, statusId, addReaction, removeReaction } = this.props;
if (reaction.get('me')) {
if (reaction.get('me') && removeReaction) {
removeReaction(statusId, reaction.get('name'));
} else {
} else if (addReaction) {
addReaction(statusId, reaction.get('name'));
}
};

View file

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

View file

@ -36,8 +36,6 @@ import Status from 'flavours/glitch/components/status';
import { deleteModal } from 'flavours/glitch/initial_state';
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
import { showAlertForError } from '../actions/alerts';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture();
@ -121,10 +119,7 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
onEmbed (status) {
dispatch(openModal({
modalType: 'EMBED',
modalProps: {
id: status.get('id'),
onError: error => dispatch(showAlertForError(error)),
},
modalProps: { id: status.get('id') },
}));
},

View 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,
},
'*',
);
});
});

View file

@ -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() {
const { messages: localeData } = getLocale();

View file

@ -131,7 +131,7 @@ class LoginForm extends React.PureComponent {
try {
new URL(url);
return true;
} catch(_) {
} catch {
return false;
}
};

View file

@ -6,7 +6,6 @@ import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
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 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 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 filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
@ -79,18 +77,6 @@ class ColumnSettings extends PureComponent {
</div>
</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'>
<h3 id='notifications-unread-markers'>
<FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />

View file

@ -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 { initBlockModal } from 'flavours/glitch/actions/blocks';
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 { Avatar } from 'flavours/glitch/components/avatar';
import { CheckBox } from 'flavours/glitch/components/check_box';
@ -40,11 +40,11 @@ export const NotificationRequest = ({ id, accountId, notificationsCount, checked
const { push: historyPush } = useHistory();
const handleDismiss = useCallback(() => {
dispatch(dismissNotificationRequest(id));
dispatch(dismissNotificationRequest({ id }));
}, [dispatch, id]);
const handleAccept = useCallback(() => {
dispatch(acceptNotificationRequest(id));
dispatch(acceptNotificationRequest({ id }));
}, [dispatch, id]);
const handleMute = useCallback(() => {

View file

@ -10,7 +10,13 @@ import { useSelector, useDispatch } from 'react-redux';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import DoneIcon from '@/material-icons/400-24px/done.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 ColumnHeader from 'flavours/glitch/components/column_header';
import { IconButton } from 'flavours/glitch/components/icon_button';
@ -44,28 +50,28 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
const columnRef = useRef();
const intl = useIntl();
const dispatch = useDispatch();
const notificationRequest = useSelector(state => state.getIn(['notificationRequests', 'current', 'item', 'id']) === id ? state.getIn(['notificationRequests', 'current', 'item']) : null);
const accountId = notificationRequest?.get('account');
const notificationRequest = useSelector(state => state.notificationRequests.current.item?.id === id ? state.notificationRequests.current.item : null);
const accountId = notificationRequest?.account_id;
const account = useSelector(state => state.getIn(['accounts', accountId]));
const notifications = useSelector(state => state.getIn(['notificationRequests', 'current', 'notifications', 'items']));
const isLoading = useSelector(state => state.getIn(['notificationRequests', 'current', 'notifications', 'isLoading']));
const hasMore = useSelector(state => !!state.getIn(['notificationRequests', 'current', 'notifications', 'next']));
const removed = useSelector(state => state.getIn(['notificationRequests', 'current', 'removed']));
const notifications = useSelector(state => state.notificationRequests.current.notifications.items);
const isLoading = useSelector(state => state.notificationRequests.current.notifications.isLoading);
const hasMore = useSelector(state => !!state.notificationRequests.current.notifications.next);
const removed = useSelector(state => state.notificationRequests.current.removed);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, [columnRef]);
const handleLoadMore = useCallback(() => {
dispatch(expandNotificationsForRequest());
}, [dispatch]);
dispatch(expandNotificationsForRequest({ accountId }));
}, [dispatch, accountId]);
const handleDismiss = useCallback(() => {
dispatch(dismissNotificationRequest(id));
dispatch(dismissNotificationRequest({ id }));
}, [dispatch, id]);
const handleAccept = useCallback(() => {
dispatch(acceptNotificationRequest(id));
dispatch(acceptNotificationRequest({ id }));
}, [dispatch, id]);
const handleMoveUp = useCallback(id => {
@ -79,12 +85,12 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
}, [columnRef, notifications]);
useEffect(() => {
dispatch(fetchNotificationRequest(id));
dispatch(fetchNotificationRequest({ id }));
}, [dispatch, id]);
useEffect(() => {
if (accountId) {
dispatch(fetchNotificationsForRequest(accountId));
dispatch(fetchNotificationsForRequest({ accountId }));
}
}, [dispatch, accountId]);

View file

@ -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 MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
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 { CheckBox } from 'flavours/glitch/components/check_box';
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 }),
confirm: intl.formatMessage(messages.confirmAcceptMultipleButton, { count: selectedItems.length}),
onConfirm: () =>
dispatch(acceptNotificationRequests(selectedItems)),
dispatch(acceptNotificationRequests({ ids: selectedItems })),
},
}));
}, [dispatch, intl, selectedItems]);
@ -97,7 +102,7 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
message: intl.formatMessage(messages.confirmDismissMultipleMessage, { count: selectedItems.length }),
confirm: intl.formatMessage(messages.confirmDismissMultipleButton, { count: selectedItems.length}),
onConfirm: () =>
dispatch(dismissNotificationRequests(selectedItems)),
dispatch(dismissNotificationRequests({ ids: selectedItems })),
},
}));
}, [dispatch, intl, selectedItems]);
@ -161,9 +166,9 @@ export const NotificationRequests = ({ multiColumn }) => {
const columnRef = useRef();
const intl = useIntl();
const dispatch = useDispatch();
const isLoading = useSelector(state => state.getIn(['notificationRequests', 'isLoading']));
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
const hasMore = useSelector(state => !!state.getIn(['notificationRequests', 'next']));
const isLoading = useSelector(state => state.notificationRequests.isLoading);
const notificationRequests = useSelector(state => state.notificationRequests.items);
const hasMore = useSelector(state => !!state.notificationRequests.next);
const [selectionMode, setSelectionMode] = useState(false);
const [checkedRequestIds, setCheckedRequestIds] = useState([]);
@ -182,7 +187,7 @@ export const NotificationRequests = ({ multiColumn }) => {
else
ids.push(id);
setSelectAllChecked(ids.length === notificationRequests.size);
setSelectAllChecked(ids.length === notificationRequests.length);
return [...ids];
});
@ -193,7 +198,7 @@ export const NotificationRequests = ({ multiColumn }) => {
if(checked)
setCheckedRequestIds([]);
else
setCheckedRequestIds(notificationRequests.map(request => request.get('id')).toArray());
setCheckedRequestIds(notificationRequests.map(request => request.id));
return !checked;
});
@ -217,7 +222,7 @@ export const NotificationRequests = ({ multiColumn }) => {
multiColumn={multiColumn}
showBackButton
appendContent={
notificationRequests.size > 0 && (
notificationRequests.length > 0 && (
<SelectRow selectionMode={selectionMode} setSelectionMode={setSelectionMode} selectAllChecked={selectAllChecked} toggleSelectAll={toggleSelectAll} selectedItems={checkedRequestIds} />
)}
>
@ -236,12 +241,12 @@ export const NotificationRequests = ({ multiColumn }) => {
>
{notificationRequests.map(request => (
<NotificationRequest
key={request.get('id')}
id={request.get('id')}
accountId={request.get('account')}
notificationsCount={request.get('notifications_count')}
key={request.id}
id={request.id}
accountId={request.account_id}
notificationsCount={request.notifications_count}
showCheckbox={selectionMode}
checked={checkedRequestIds.includes(request.get('id'))}
checked={checkedRequestIds.includes(request.id)}
toggleCheck={handleCheck}
/>
))}

View file

@ -1,5 +1,7 @@
import { FormattedMessage } from 'react-intl';
import { isEqual } from 'lodash';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
import { me } from 'flavours/glitch/initial_state';
@ -47,7 +49,7 @@ export const NotificationMention: React.FC<{
status.get('visibility') === 'direct',
status.get('in_reply_to_account_id') === me,
] as const;
});
}, isEqual);
let labelRenderer = mentionLabelRenderer;

View file

@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { isEqual } from 'lodash';
import { useDebouncedCallback } from 'use-debounce';
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
@ -62,7 +63,7 @@ export const Notifications: React.FC<{
multiColumn?: boolean;
}> = ({ columnId, multiColumn }) => {
const intl = useIntl();
const notifications = useAppSelector(selectNotificationGroups);
const notifications = useAppSelector(selectNotificationGroups, isEqual);
const dispatch = useAppDispatch();
const isLoading = useAppSelector((s) => s.notificationGroups.isLoading);
const hasMore = notifications.at(-1)?.type === 'gap';

View file

@ -1,13 +1,8 @@
import Notifications from 'flavours/glitch/features/notifications';
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) => {
const optedInGroupedNotifications = useAppSelector(selectUseGroupedNotifications);
return (
optedInGroupedNotifications ? <Notifications_v2 {...props} /> : <Notifications {...props} />
<Notifications_v2 {...props} />
);
};

View file

@ -10,8 +10,8 @@ import { Link } from 'react-router-dom';
import SwipeableViews from 'react-swipeable-views';
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 { CopyPasteText } from 'flavours/glitch/components/copy_paste_text';
import { Icon } from 'flavours/glitch/components/icon';
import { me, domain } from 'flavours/glitch/initial_state';
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}' },
});
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 {
static propTypes = {

View file

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

View file

@ -52,7 +52,7 @@ const messages = defineMessages({
share: { id: 'status.share', defaultMessage: 'Share' },
pin: { id: 'status.pin', defaultMessage: 'Pin on 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_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}' },

View file

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

View file

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

View file

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

View file

@ -65,7 +65,7 @@ import Column from '../ui/components/column';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
import ActionBar from './components/action_bar';
import DetailedStatus from './components/detailed_status';
import { DetailedStatus } from './components/detailed_status';
const messages = defineMessages({

View file

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

View file

@ -64,8 +64,8 @@ export default class ColumnsArea extends ImmutablePureComponent {
openSettings: PropTypes.func,
};
// Corresponds to (max-width: $no-gap-breakpoint + 285px - 1px) in SCSS
mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1174px)');
// Corresponds to (max-width: $no-gap-breakpoint - 1px) in SCSS
mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1206px)');
state = {
renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),

View file

@ -79,7 +79,10 @@ export const ConfirmationModal: React.FC<
/>
</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>

View file

@ -88,7 +88,7 @@ export const DomainBlockModal = ({ domain, accountId, acct }) => {
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
</button>
<Button onClick={handleClick}>
<Button onClick={handleClick} autoFocus>
<FormattedMessage id='domain_block_modal.block' defaultMessage='Block server' />
</Button>
</div>

View file

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

View file

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

View file

@ -137,7 +137,7 @@ export const MuteModal = ({ accountId, acct }) => {
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
</button>
<Button onClick={handleClick}>
<Button onClick={handleClick} autoFocus>
<FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' />
</Button>
</div>

View file

@ -36,7 +36,6 @@ import { timelinePreview, trendsEnabled } from 'flavours/glitch/initial_state';
import { transientSingleColumn } from 'flavours/glitch/is_mobile';
import { canManageReports, canViewAdminDashboard } from 'flavours/glitch/permissions';
import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications';
import { selectUseGroupedNotifications } from 'flavours/glitch/selectors/settings';
import { preferencesLink } from 'flavours/glitch/utils/backend_links';
import ColumnLink from './column_link';
@ -66,19 +65,17 @@ const messages = defineMessages({
});
const NotificationsLink = () => {
const optedInGroupedNotifications = useSelector(selectUseGroupedNotifications);
const count = useSelector(state => state.getIn(['local_settings', 'notifications', 'tab_badge']) ? state.getIn(['notifications', 'unread']) : 0);
const count = useSelector(selectUnreadNotificationGroupsCount);
const showCount = useSelector(state => state.getIn(['local_settings', 'notifications', 'tab_badge']));
const intl = useIntl();
const newCount = useSelector(selectUnreadNotificationGroupsCount);
return (
<ColumnLink
key='notifications'
transparent
to='/notifications'
icon={<IconWithBadge id='bell' icon={NotificationsIcon} count={optedInGroupedNotifications ? newCount : count} className='column-link__icon' />}
activeIcon={<IconWithBadge id='bell' icon={NotificationsActiveIcon} 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={showCount ? count : 0} className='column-link__icon' />}
text={intl.formatMessage(messages.notifications)}
/>
);

View file

@ -3,10 +3,12 @@ import { connect } from 'react-redux';
import { openModal, closeModal } from '../../../actions/modal';
import ModalRoot from '../components/modal_root';
const defaultProps = {};
const mapStateToProps = state => ({
ignoreFocus: state.getIn(['modal', 'ignoreFocus']),
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 => ({

View file

@ -4,24 +4,11 @@ import { connect } from 'react-redux';
import { NotificationStack } from 'react-notification';
import { dismissAlert } from '../../../actions/alerts';
import { getAlerts } from '../../../selectors';
const formatIfNeeded = (intl, message, values) => {
if (typeof message === 'object') {
return intl.formatMessage(message, values);
}
return message;
};
import { dismissAlert } from 'flavours/glitch/actions/alerts';
import { getAlerts } from 'flavours/glitch/selectors';
const mapStateToProps = (state, { intl }) => ({
notifications: getAlerts(state).map(alert => ({
...alert,
action: formatIfNeeded(intl, alert.action, alert.values),
title: formatIfNeeded(intl, alert.title, alert.values),
message: formatIfNeeded(intl, alert.message, alert.values),
})),
notifications: getAlerts(state, { intl }),
});
const mapDispatchToProps = (dispatch) => ({

View file

@ -15,7 +15,7 @@ const getRegex = createSelector([
try {
regex = rawRegex && new RegExp(rawRegex.trim(), 'i');
} catch (e) {
} catch {
// Bad regex, don't affect filters
}
return regex;

View file

@ -337,8 +337,8 @@ class UI extends PureComponent {
try {
e.dataTransfer.dropEffect = 'copy';
} catch (err) {
} catch {
// do nothing
}
return false;

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

View file

@ -48,7 +48,6 @@
* @property {string} version
* @property {number} visible_reactions
* @property {string} sso_redirect
* @property {boolean} force_grouped_notifications
* @property {string} status_page_url
* @property {boolean} system_emoji_font
* @property {string} default_content_type
@ -102,7 +101,7 @@ if (initialState) {
try {
// @ts-expect-error
initialState.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
} catch (e) {
} catch {
initialState.local_settings = {};
}
}
@ -152,7 +151,6 @@ export const languages = initialState?.languages;
export const criticalUpdatesPending = initialState?.critical_updates_pending;
export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect');
export const forceGroupedNotifications = getMeta('force_grouped_notifications');
// Glitch-soc-specific settings
export const maxFeedHashtags = (initialState && initialState.max_feed_hashtags) || 4;

View file

@ -17,7 +17,7 @@ function onProviderError(error: unknown) {
error &&
typeof error === 'object' &&
error instanceof Error &&
error.message.match('MISSING_DATA')
/MISSING_DATA/.exec(error.message)
) {
console.warn(error.message);
}

View file

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

View file

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

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

View file

@ -55,11 +55,11 @@ const initialState = ImmutableMap({
markNewForDelete: false,
});
export const notificationToMap = (notification, markForDelete = false) => ImmutableMap({
export const notificationToMap = (notification) => ImmutableMap({
id: notification.id,
type: notification.type,
account: notification.account.id,
markedForDelete: markForDelete,
markedForDelete: false,
status: notification.status ? notification.status.id : null,
report: notification.report ? fromJS(notification.report) : null,
event: notification.event ? fromJS(notification.event) : null,
@ -76,7 +76,7 @@ const normalizeNotification = (state, notification, usePendingItems) => {
}
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)) {
@ -90,7 +90,7 @@ const normalizeNotification = (state, notification, usePendingItems) => {
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 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 => {
if (!newItems.isEmpty()) {

View file

@ -7,14 +7,16 @@ import { me } from '../initial_state';
export { makeGetAccount } from "./accounts";
const getFilters = (state, { contextType }) => {
if (!contextType) return null;
const getFilters = createSelector([state => state.get('filters'), (_, { contextType }) => contextType], (filters, contextType) => {
if (!contextType) {
return null;
}
const serverSideType = toServerSideType(contextType);
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 = () => {
return createSelector(
@ -74,10 +76,21 @@ const ALERT_DEFAULTS = {
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 => ({
...ALERT_DEFAULTS,
...item,
action: formatIfNeeded(intl, item.action, item.values),
title: formatIfNeeded(intl, item.title, item.values),
message: formatIfNeeded(intl, item.message, item.values),
})).toArray());
export const makeGetNotification = () => createSelector([

View file

@ -1,18 +1,27 @@
import { forceGroupedNotifications } from 'flavours/glitch/initial_state';
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from 'flavours/glitch/store';
/* 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
export const selectSettingsNotificationsShows = (state: RootState) =>
state.settings.getIn(['notifications', 'shows']).toJS() as Record<
string,
boolean
>;
export const selectSettingsNotificationsShows = createSelector(
[
(state) =>
state.settings.getIn(['notifications', 'shows']) as Immutable.Map<
string,
boolean
>,
],
(shows) => shows.toJS() as Record<string, boolean>,
);
export const selectSettingsNotificationsExcludedTypes = (state: RootState) =>
Object.entries(selectSettingsNotificationsShows(state))
.filter(([_type, enabled]) => !enabled)
.map(([type, _enabled]) => type);
export const selectSettingsNotificationsExcludedTypes = createSelector(
[selectSettingsNotificationsShows],
(shows) =>
Object.entries(shows)
.filter(([_type, enabled]) => !enabled)
.map(([type, _enabled]) => type),
);
export const selectSettingsNotificationsQuickFilterShow = (state: RootState) =>
state.settings.getIn(['notifications', 'quickFilter', 'show']) as boolean;
@ -26,10 +35,6 @@ export const selectSettingsNotificationsQuickFilterAdvanced = (
) =>
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) =>
state.settings.getIn(['notifications', 'showUnread']) as boolean;

View file

@ -14,7 +14,7 @@ export default class Settings {
const encodedData = JSON.stringify(data);
localStorage.setItem(key, encodedData);
return data;
} catch (e) {
} catch {
return null;
}
}
@ -24,7 +24,7 @@ export default class Settings {
try {
const rawData = localStorage.getItem(key);
return JSON.parse(rawData);
} catch (e) {
} catch {
return null;
}
}
@ -35,7 +35,8 @@ export default class Settings {
const key = this.generateKey(id);
try {
localStorage.removeItem(key);
} catch (e) {
} catch {
// ignore if the key is not found
}
}
return data;

View file

@ -30,7 +30,7 @@ function isActionWithmaybeAlertParams(
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> =
({ dispatch }) =>
(next) =>

View file

@ -51,7 +51,7 @@ const play = (audio: HTMLAudioElement) => {
};
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
> => {

View file

@ -33,8 +33,12 @@ interface AppThunkConfig {
}
type AppThunkApi = Pick<GetThunkAPI<AppThunkConfig>, 'getState' | 'dispatch'>;
interface AppThunkOptions {
interface AppThunkOptions<Arg> {
useLoadingBar?: boolean;
condition?: (
arg: Arg,
{ getState }: { getState: AppThunkApi['getState'] },
) => boolean;
}
const createBaseAsyncThunk = createAsyncThunk.withTypes<AppThunkConfig>();
@ -42,7 +46,7 @@ const createBaseAsyncThunk = createAsyncThunk.withTypes<AppThunkConfig>();
export function createThunk<Arg = void, Returned = void>(
name: string,
creator: (arg: Arg, api: AppThunkApi) => Returned | Promise<Returned>,
options: AppThunkOptions = {},
options: AppThunkOptions<Arg> = {},
) {
return createBaseAsyncThunk(
name,
@ -70,6 +74,7 @@ export function createThunk<Arg = void, Returned = void>(
if (options.useLoadingBar) return { useLoadingBar: true };
return {};
},
condition: options.condition,
},
);
}
@ -96,7 +101,7 @@ type ArgsType = Record<string, unknown> | undefined;
export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
name: string,
loadData: (args: Args) => Promise<LoadDataResult>,
thunkOptions?: AppThunkOptions,
thunkOptions?: AppThunkOptions<Args>,
): ReturnType<typeof createThunk<Args, LoadDataResult>>;
// 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,
loadData: LoadData<Args, LoadDataResult>,
onDataOrThunkOptions?:
| AppThunkOptions
| AppThunkOptions<Args>
| OnData<Args, LoadDataResult, DiscardLoadData>,
thunkOptions?: AppThunkOptions,
thunkOptions?: AppThunkOptions<Args>,
): ReturnType<typeof createThunk<Args, void>>;
// Overload when the `onData` method returns nothing, then the mayload is the `onData` result
export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
name: string,
loadData: LoadData<Args, LoadDataResult>,
onDataOrThunkOptions?: AppThunkOptions | OnData<Args, LoadDataResult, void>,
thunkOptions?: AppThunkOptions,
onDataOrThunkOptions?:
| AppThunkOptions<Args>
| OnData<Args, LoadDataResult, void>,
thunkOptions?: AppThunkOptions<Args>,
): ReturnType<typeof createThunk<Args, LoadDataResult>>;
// Overload when there is an `onData` method returning something
@ -126,9 +133,9 @@ export function createDataLoadingThunk<
name: string,
loadData: LoadData<Args, LoadDataResult>,
onDataOrThunkOptions?:
| AppThunkOptions
| AppThunkOptions<Args>
| OnData<Args, LoadDataResult, Returned>,
thunkOptions?: AppThunkOptions,
thunkOptions?: AppThunkOptions<Args>,
): ReturnType<typeof createThunk<Args, Returned>>;
/**
@ -154,6 +161,7 @@ export function createDataLoadingThunk<
* @param maybeThunkOptions
* Additional Mastodon specific options for the thunk. Currently supports:
* - `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
*/
export function createDataLoadingThunk<
@ -164,12 +172,12 @@ export function createDataLoadingThunk<
name: string,
loadData: LoadData<Args, LoadDataResult>,
onDataOrThunkOptions?:
| AppThunkOptions
| AppThunkOptions<Args>
| OnData<Args, LoadDataResult, Returned>,
maybeThunkOptions?: AppThunkOptions,
maybeThunkOptions?: AppThunkOptions<Args>,
) {
let onData: OnData<Args, LoadDataResult, Returned> | undefined;
let thunkOptions: AppThunkOptions | undefined;
let thunkOptions: AppThunkOptions<Args> | undefined;
if (typeof onDataOrThunkOptions === 'function') onData = onDataOrThunkOptions;
else if (typeof onDataOrThunkOptions === 'object')
@ -203,6 +211,9 @@ export function createDataLoadingThunk<
return undefined as Returned;
else return result;
},
{ useLoadingBar: thunkOptions?.useLoadingBar ?? true },
{
useLoadingBar: thunkOptions?.useLoadingBar ?? true,
condition: thunkOptions?.condition,
},
);
}

View file

@ -11,7 +11,6 @@
@import 'widgets';
@import 'forms';
@import 'accounts';
@import 'statuses';
@import 'components';
@import 'polls';
@import 'modal';

View file

@ -1781,15 +1781,29 @@ body > [data-popper-placement] {
.status__prepend {
padding: 8px 14px; // glitch: reduced padding
padding-bottom: 0;
display: inline-flex;
gap: 10px;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
line-height: 22px;
font-weight: 500;
color: $dark-text-color;
.status__display-name strong {
color: $dark-text-color;
&__icon {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
.icon {
width: 16px;
height: 16px;
}
}
a {
color: inherit;
text-decoration: none;
}
> span {
@ -1848,18 +1862,6 @@ body > [data-popper-placement] {
padding: 14px 10px; // glitch: reduced padding
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 {
font-size: 19px;
line-height: 24px;
@ -1886,6 +1888,29 @@ body > [data-popper-placement] {
padding: 0;
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 {
@ -4096,8 +4121,8 @@ input.glitch-setting-text {
}
&__wrapper {
background: $white;
border: 1px solid $ui-secondary-color;
background: $ui-base-color;
border: 1px solid var(--background-border-color);
margin-bottom: 10px;
border-radius: 4px;
@ -5049,22 +5074,14 @@ a.status-card {
position: absolute;
z-index: 100;
&--minified {
display: block;
inset-inline-start: 4px;
top: 4px;
width: auto;
height: auto;
&--hidden {
display: none;
}
&--click-thru {
pointer-events: none;
}
&--hidden {
display: none;
}
&__overlay {
display: flex;
align-items: center;
@ -5076,19 +5093,20 @@ a.status-card {
margin: 0;
border: 0;
color: $white;
line-height: 20px;
font-size: 14px;
&__label {
background-color: rgba($black, 0.45);
backdrop-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%);
border-radius: 6px;
padding: 10px 15px;
border-radius: 8px;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
gap: 4px;
flex-direction: column;
font-weight: 500;
font-size: 14px;
font-weight: 600;
}
&__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 {
display: inline-flex;
align-items: center;
@ -6757,26 +6819,20 @@ a.status-card {
.report-modal,
.actions-modal,
.compare-history-modal {
background: lighten($ui-secondary-color, 8%);
color: $inverted-text-color;
border-radius: 8px;
background: var(--background-color);
color: $primary-text-color;
border-radius: 4px;
border: 1px solid var(--background-border-color);
overflow: hidden;
max-width: 90vw;
width: 480px;
position: relative;
flex-direction: column;
.status__relative-time {
order: 2;
@media screen and (max-width: $no-columns-breakpoint) {
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 {
@ -6816,6 +6872,7 @@ a.status-card {
.report-modal {
width: 90vw;
max-width: 700px;
border: 1px solid var(--background-border-color);
}
.report-dialog-modal {
@ -7039,7 +7096,7 @@ a.status-card {
.report-modal__container {
display: flex;
border-top: 1px solid $ui-secondary-color;
border-top: 1px solid var(--background-border-color);
@media screen and (width <= 480px) {
flex-wrap: wrap;
@ -7097,7 +7154,7 @@ a.status-card {
.report-modal__comment {
padding: 20px;
border-inline-end: 1px solid $ui-secondary-color;
border-inline-end: 1px solid var(--background-border-color);
max-width: 320px;
p {
@ -7108,7 +7165,7 @@ a.status-card {
.setting-text-label {
display: block;
color: $inverted-text-color;
color: $secondary-text-color;
font-size: 14px;
font-weight: 500;
margin-bottom: 10px;
@ -7174,7 +7231,7 @@ a.status-card {
li:not(:empty) {
a {
color: $inverted-text-color;
color: $primary-text-color;
display: flex;
padding: 12px 16px;
font-size: 15px;
@ -7267,7 +7324,7 @@ a.status-card {
.compare-history-modal {
.report-modal__target {
border-bottom: 1px solid $ui-secondary-color;
border-bottom: 1px solid var(--background-border-color);
}
&__container {
@ -7277,7 +7334,7 @@ a.status-card {
}
.status__content {
color: $inverted-text-color;
color: $secondary-text-color;
font-size: 19px;
line-height: 24px;
@ -7339,12 +7396,92 @@ img.modal-warning {
}
}
.media-gallery__item__badges {
.media-gallery__actions {
position: absolute;
bottom: 6px;
inset-inline-start: 6px;
inset-inline-end: 6px;
display: flex;
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,
@ -7355,18 +7492,13 @@ img.modal-warning {
color: $white;
background: rgba($black, 0.65);
backdrop-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%);
padding: 2px 6px;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-size: 12px;
font-weight: 700;
z-index: 1;
pointer-events: none;
line-height: 18px;
.icon {
width: 15px;
height: 15px;
}
line-height: 20px;
}
.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,
.follow-request-banner,
.account-memorial-banner {
@ -10711,6 +10780,7 @@ noscript {
scroll-padding: 16px;
scroll-behavior: smooth;
overflow-x: scroll;
scrollbar-width: none;
&__card {
background: var(--background-color);

View file

@ -12,6 +12,41 @@ code {
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 {
display: flex;
align-items: center;

View file

@ -147,28 +147,6 @@
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:focus,
.reactions-bar__item:active {
@ -198,14 +176,6 @@
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 {
color: $white;
}

View file

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

View file

@ -87,7 +87,7 @@ $media-modal-media-max-width: 100%;
// put margins on top and bottom of image to avoid the screen covered by image.
$media-modal-media-max-height: 80%;
$no-gap-breakpoint: 1175px;
$no-gap-breakpoint: 1207px;
$mobile-breakpoint: 630px;
$font-sans-serif: 'mastodon-font-sans-serif' !default;

View file

@ -11,7 +11,7 @@ function _autoUnfoldCW(spoiler_text, skip_unfold_regex) {
try {
regex = new RegExp(skip_unfold_regex.trim(), 'i');
} catch (e) {
} catch {
// Bad regex, skip filters
return true;
}

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

View file

@ -2,7 +2,6 @@ import { debounce } from 'lodash';
import type { MarkerJSON } from 'mastodon/api_types/markers';
import { getAccessToken } from 'mastodon/initial_state';
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
import type { AppDispatch, RootState } from 'mastodon/store';
import { createAppAsyncThunk } from 'mastodon/store/typed_functions';
@ -65,7 +64,7 @@ export const synchronouslySubmitMarkers = createAppAsyncThunk(
client.setRequestHeader('Content-Type', 'application/json');
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
client.send(JSON.stringify(params));
} catch (e) {
} catch {
// Do not make the BeforeUnload handler error out
}
},
@ -76,12 +75,7 @@ interface MarkerParam {
}
function getLastNotificationId(state: RootState): string | undefined {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
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']);
return state.notificationGroups.lastReadId;
}
const buildPostMarkersParams = (state: RootState) => {

View file

@ -2,7 +2,7 @@ import { createAction } from '@reduxjs/toolkit';
import {
apiClearNotifications,
apiFetchNotifications,
apiFetchNotificationGroups,
} from 'mastodon/api/notifications';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import type {
@ -71,7 +71,7 @@ function dispatchAssociatedRecords(
export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch',
async (_params, { getState }) =>
apiFetchNotifications({ exclude_types: getExcludedTypes(getState()) }),
apiFetchNotificationGroups({ exclude_types: getExcludedTypes(getState()) }),
({ notifications, accounts, statuses }, { dispatch }) => {
dispatch(importFetchedAccounts(accounts));
dispatch(importFetchedStatuses(statuses));
@ -92,7 +92,7 @@ export const fetchNotifications = createDataLoadingThunk(
export const fetchNotificationsGap = createDataLoadingThunk(
'notificationGroups/fetchGap',
async (params: { gap: NotificationGap }, { getState }) =>
apiFetchNotifications({
apiFetchNotificationGroups({
max_id: params.gap.maxId,
exclude_types: getExcludedTypes(getState()),
}),
@ -108,7 +108,7 @@ export const fetchNotificationsGap = createDataLoadingThunk(
export const pollRecentNotifications = createDataLoadingThunk(
'notificationGroups/pollRecentNotifications',
async (_params, { getState }) => {
return apiFetchNotifications({
return apiFetchNotificationGroups({
max_id: undefined,
exclude_types: getExcludedTypes(getState()),
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones

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

View file

@ -18,7 +18,6 @@ import {
importFetchedStatuses,
} from './importer';
import { submitMarkers } from './markers';
import { decreasePendingNotificationsCount } from './notification_policies';
import { notificationsUpdate } from "./notifications_typed";
import { register as registerPushNotifications } from './push_notifications';
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_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_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS';
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_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({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
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 = () => ({
type: NOTIFICATIONS_LOAD_PENDING,
});
@ -343,296 +308,3 @@ export function setBrowserPermission (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,
});

View file

@ -1,14 +1,10 @@
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
import { createAppAsyncThunk } from 'mastodon/store';
import { fetchNotifications } from './notification_groups';
import { expandNotifications } from './notifications';
export const initializeNotifications = createAppAsyncThunk(
'notifications/initialize',
(_, { dispatch, getState }) => {
if (selectUseGroupedNotifications(getState()))
void dispatch(fetchNotifications());
else void dispatch(expandNotifications({}));
(_, { dispatch }) => {
void dispatch(fetchNotifications());
},
);

View file

@ -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) => {
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
dispatch(fetchContext(id));
if (alsoFetchContext) {
dispatch(fetchContext(id));
}
if (skipLoading) {
return;

View file

@ -1,7 +1,5 @@
// @ts-check
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
import { getLocale } from '../locales';
import { connectStream } from '../stream';
@ -105,18 +103,14 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
const notificationJSON = JSON.parse(data.payload);
dispatch(updateNotifications(notificationJSON, messages, locale));
// TODO: remove this once the groups feature replaces the previous one
if(selectUseGroupedNotifications(getState())) {
dispatch(processNewNotificationForGroups(notificationJSON));
}
dispatch(processNewNotificationForGroups(notificationJSON));
break;
}
case 'notifications_merged':
const state = getState();
if (state.notifications.top || !state.notifications.mounted)
dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
if (selectUseGroupedNotifications(state)) {
dispatch(refreshStaleNotificationGroups());
}
dispatch(refreshStaleNotificationGroups());
break;
case 'conversation':
// @ts-expect-error
@ -141,21 +135,15 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
/**
* @param {Function} dispatch
* @param {Function} getState
*/
async function refreshHomeTimelineAndNotification(dispatch, getState) {
async function refreshHomeTimelineAndNotification(dispatch) {
await dispatch(expandHomeTimeline({ maxId: undefined }));
// TODO: remove this once the groups feature replaces the previous one
if(selectUseGroupedNotifications(getState())) {
// TODO: polling for merged notifications
try {
await dispatch(pollRecentGroupNotifications());
} catch (error) {
// TODO
}
} else {
await dispatch(expandNotifications({}));
// TODO: polling for merged notifications
try {
await dispatch(pollRecentGroupNotifications());
} catch {
// TODO
}
await dispatch(fetchAnnouncements());

View file

@ -1,14 +1,43 @@
import api, { apiRequest, getLinks } from 'mastodon/api';
import type { ApiNotificationGroupsResultJSON } from 'mastodon/api_types/notifications';
import api, {
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[];
max_id?: string;
since_id?: string;
}) => {
const response = await api().request<ApiNotificationGroupsResultJSON>({
method: 'GET',
url: '/api/v2_alpha/notifications',
url: '/api/v2/notifications',
params,
});
@ -24,3 +53,43 @@ export const apiFetchNotifications = async (params?: {
export const apiClearNotifications = () =>
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 });
};

View file

@ -149,3 +149,12 @@ export interface ApiNotificationGroupsResultJSON {
statuses: ApiStatusJSON[];
notification_groups: ApiNotificationGroupJSON[];
}
export interface ApiNotificationRequestJSON {
id: string;
created_at: string;
updated_at: string;
notifications_count: string;
account: ApiAccountJSON;
last_status?: ApiStatusJSON;
}

View file

@ -5,7 +5,7 @@ export function start() {
try {
Rails.start();
} catch (e) {
} catch {
// If called twice
}
}

View 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