diff --git a/.eslintrc.js b/.eslintrc.js index 66a0f12ba1..f9ddb42ae0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -333,7 +333,7 @@ module.exports = defineConfig({ ], parserOptions: { - project: true, + projectService: true, tsconfigRootDir: __dirname, }, diff --git a/.github/codecov.yml b/.github/codecov.yml index d9b43b2594..21af6d0d45 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -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 diff --git a/.rubocop/strict.yml b/.rubocop/strict.yml index 2222c6d8b9..c2655a1470 100644 --- a/.rubocop/strict.yml +++ b/.rubocop/strict.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6f9672da..70ff89dec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG_glitch.md b/CHANGELOG_glitch.md index 02ac2898dd..8d1e0bfcf7 100644 --- a/CHANGELOG_glitch.md +++ b/CHANGELOG_glitch.md @@ -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 ``` 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) diff --git a/Gemfile b/Gemfile index 4c808fa480..4cce095ec5 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 48fd05b1ac..4a139155f5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/controllers/api/v2_alpha/notifications/accounts_controller.rb b/app/controllers/api/v2/notifications/accounts_controller.rb similarity index 77% rename from app/controllers/api/v2_alpha/notifications/accounts_controller.rb rename to app/controllers/api/v2/notifications/accounts_controller.rb index 9933b63373..771e966388 100644 --- a/app/controllers/api/v2_alpha/notifications/accounts_controller.rb +++ b/app/controllers/api/v2/notifications/accounts_controller.rb @@ -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 diff --git a/app/controllers/api/v2_alpha/notifications_controller.rb b/app/controllers/api/v2/notifications_controller.rb similarity index 89% rename from app/controllers/api/v2_alpha/notifications_controller.rb rename to app/controllers/api/v2/notifications_controller.rb index e8aa0b9e49..c070c0e5e7 100644 --- a/app/controllers/api/v2_alpha/notifications_controller.rb +++ b/app/controllers/api/v2/notifications_controller.rb @@ -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 diff --git a/app/controllers/concerns/web_app_controller_concern.rb b/app/controllers/concerns/web_app_controller_concern.rb index 24cccf1667..d43a658421 100644 --- a/app/controllers/concerns/web_app_controller_concern.rb +++ b/app/controllers/concerns/web_app_controller_concern.rb @@ -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? diff --git a/app/controllers/redirect/base_controller.rb b/app/controllers/redirect/base_controller.rb index 90894ec1ed..34558a4126 100644 --- a/app/controllers/redirect/base_controller.rb +++ b/app/controllers/redirect/base_controller.rb @@ -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 diff --git a/app/controllers/settings/verifications_controller.rb b/app/controllers/settings/verifications_controller.rb index fc4f23bb18..4e0663253c 100644 --- a/app/controllers/settings/verifications_controller.rb +++ b/app/controllers/settings/verifications_controller.rb @@ -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 diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index a0885b469b..341b0e6472 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -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)]]] diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index 110a53e4e1..8e32a92923 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -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 diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index d70b2a88fd..71e19207d0 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -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 diff --git a/app/helpers/media_component_helper.rb b/app/helpers/media_component_helper.rb index fa8f34fb4d..60ccdd0835 100644 --- a/app/helpers/media_component_helper.rb +++ b/app/helpers/media_component_helper.rb @@ -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 diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index d956e4fcd8..bba6d64a47 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -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? diff --git a/app/javascript/entrypoints/embed.tsx b/app/javascript/entrypoints/embed.tsx new file mode 100644 index 0000000000..f8c824d287 --- /dev/null +++ b/app/javascript/entrypoints/embed.tsx @@ -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(); + } +} + +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, + }, + '*', + ); + }); +}); diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index b06675c2ee..d33e00d5da 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -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(); diff --git a/app/javascript/flavours/glitch/actions/markers.ts b/app/javascript/flavours/glitch/actions/markers.ts index 1c1fd60dcf..37067dbcf7 100644 --- a/app/javascript/flavours/glitch/actions/markers.ts +++ b/app/javascript/flavours/glitch/actions/markers.ts @@ -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) => { diff --git a/app/javascript/flavours/glitch/actions/notification_groups.ts b/app/javascript/flavours/glitch/actions/notification_groups.ts index b0a54ad5b4..9b5a1ffeef 100644 --- a/app/javascript/flavours/glitch/actions/notification_groups.ts +++ b/app/javascript/flavours/glitch/actions/notification_groups.ts @@ -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 diff --git a/app/javascript/flavours/glitch/actions/notification_requests.ts b/app/javascript/flavours/glitch/actions/notification_requests.ts new file mode 100644 index 0000000000..5e3bfd88e7 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/notification_requests.ts @@ -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; + }, +); diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 6b9c6b679e..8bea3fae4c 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -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, -}); diff --git a/app/javascript/flavours/glitch/actions/notifications_migration.tsx b/app/javascript/flavours/glitch/actions/notifications_migration.tsx index 6789dbf38c..6f2d38b9e9 100644 --- a/app/javascript/flavours/glitch/actions/notifications_migration.tsx +++ b/app/javascript/flavours/glitch/actions/notifications_migration.tsx @@ -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()); }, ); diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index 2d83ae9926..2b6b589b37 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -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; diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index 3584a25625..7948984fba 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -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()); diff --git a/app/javascript/flavours/glitch/api/notifications.ts b/app/javascript/flavours/glitch/api/notifications.ts index f1e3e4e5a2..a3055e3f3e 100644 --- a/app/javascript/flavours/glitch/api/notifications.ts +++ b/app/javascript/flavours/glitch/api/notifications.ts @@ -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({ + 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({ 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('POST', 'v1/notifications/clear'); + +export const apiFetchNotificationRequests = async ( + params?: { + since_id?: string; + }, + url?: string, +) => { + const response = await api().request({ + method: 'GET', + url: url ?? '/api/v1/notifications/requests', + params, + }); + + return { + requests: response.data, + links: getLinks(response), + }; +}; + +export const apiFetchNotificationRequest = async (id: string) => { + return apiRequestGet( + `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 }); +}; diff --git a/app/javascript/flavours/glitch/api_types/notifications.ts b/app/javascript/flavours/glitch/api_types/notifications.ts index 7b9220facb..0175bac0d1 100644 --- a/app/javascript/flavours/glitch/api_types/notifications.ts +++ b/app/javascript/flavours/glitch/api_types/notifications.ts @@ -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; +} diff --git a/app/javascript/flavours/glitch/common.js b/app/javascript/flavours/glitch/common.js index ca4703d38b..71f45dbf3b 100644 --- a/app/javascript/flavours/glitch/common.js +++ b/app/javascript/flavours/glitch/common.js @@ -5,7 +5,7 @@ export function start() { try { Rails.start(); - } catch (e) { + } catch { // If called twice } } diff --git a/app/javascript/flavours/glitch/components/copy_paste_text.tsx b/app/javascript/flavours/glitch/components/copy_paste_text.tsx new file mode 100644 index 0000000000..053654b6e0 --- /dev/null +++ b/app/javascript/flavours/glitch/components/copy_paste_text.tsx @@ -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(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 ( +
+