1
0
mirror of https://github.com/funamitech/mastodon synced 2024-11-30 15:58:28 +09:00
This commit is contained in:
Noa Himesaka 2024-09-28 23:23:16 +09:00
commit 262ddc00f4
1480 changed files with 18732 additions and 13580 deletions

View File

@ -1,6 +1,7 @@
[production]
defaults
> 0.2%
firefox >= 78
ios >= 15.6
not dead
not OperaMini all

View File

@ -11,5 +11,8 @@ RUN apt-get update && \
export DEBIAN_FRONTEND=noninteractive && \
apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libvips42 libpam-dev
# Disable download prompt for Corepack
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# Move welcome message to where VS Code expects it
COPY .devcontainer/welcome-message.txt /usr/local/etc/vscode-dev-containers/first-run-notice.txt

View File

@ -39,7 +39,7 @@
},
"onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
"postCreateCommand": "COREPACK_ENABLE_DOWNLOAD_PROMPT=0 bin/setup",
"postCreateCommand": "bin/setup",
"waitFor": "postCreateCommand",
"customizations": {

View File

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

View File

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

2
.github/codecov.yml vendored
View File

@ -9,3 +9,5 @@ coverage:
default:
# GitHub status check is not blocking
informational: true
github_checks:
annotations: false

View File

@ -7,6 +7,7 @@
':prConcurrentLimitNone', // Remove limit for open PRs at any time.
':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour.
],
rebaseWhen: 'conflicted',
minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it
// packageRules order is important, they are applied from top to bottom and are merged,
// meaning the most important ones must be at the bottom, for example grouping rules

View File

@ -42,11 +42,24 @@ jobs:
with:
onlyProduction: 'true'
- name: Cache assets from compilation
uses: actions/cache@v4
with:
path: |
public/assets
public/packs
public/packs-test
tmp/cache/webpacker
key: ${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
restore-keys: |
${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}
${{ matrix.mode }}-assets-main
${{ matrix.mode }}-assets
- name: Precompile assets
# Previously had set this, but it's not supported
# export NODE_OPTIONS=--openssl-legacy-provider
run: |-
./bin/rails assets:precompile
bin/rails assets:precompile
- name: Archive asset artifacts
run: |
@ -137,6 +150,19 @@ jobs:
bin/rails db:setup
bin/flatware fan bin/rails db:test:prepare
- name: Cache RSpec persistence file
uses: actions/cache@v4
with:
path: |
tmp/rspec/examples.txt
key: rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
restore-keys: |
rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }}-${{ matrix.ruby-version }}
rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
rspec-persistence-${{ github.head_ref || github.ref_name }}
rspec-persistence-main
rspec-persistence
- run: bin/flatware rspec -r ./spec/flatware_helper.rb
- name: Upload coverage reports to Codecov

3
.gitignore vendored
View File

@ -71,3 +71,6 @@ docker-compose.override.yml
# Ignore dotenv .local files
.env*.local
# Ignore local-only rspec configuration
.rspec-local

View File

@ -1 +0,0 @@
LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/app/.apt/lib/x86_64-linux-gnu:/app/.apt/usr/lib/x86_64-linux-gnu/mesa:/app/.apt/usr/lib/x86_64-linux-gnu/pulseaudio:/app/.apt/usr/lib/x86_64-linux-gnu/openblas-pthread

1
.rspec
View File

@ -1,3 +1,2 @@
--color
--require spec_helper
--format Fuubar

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

@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.65.0.
# using RuboCop version 1.66.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
@ -35,7 +35,6 @@ Rails/OutputSafety:
# Configuration parameters: AllowedVars.
Style/FetchEnvVar:
Exclude:
- 'app/lib/redis_configuration.rb'
- 'app/lib/translation_service.rb'
- 'config/environments/production.rb'
- 'config/initializers/2_limited_federation_mode.rb'
@ -44,7 +43,6 @@ Style/FetchEnvVar:
- 'config/initializers/devise.rb'
- 'config/initializers/paperclip.rb'
- 'config/initializers/vapid.rb'
- 'lib/mastodon/redis_config.rb'
- 'lib/tasks/repo.rake'
# This cop supports safe autocorrection (--autocorrect).
@ -93,7 +91,6 @@ Style/OptionalBooleanParameter:
- 'app/services/fetch_resource_service.rb'
- 'app/workers/domain_block_worker.rb'
- 'app/workers/unfollow_follow_worker.rb'
- 'lib/mastodon/redis_config.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: EnforcedStyle.

View File

@ -1 +1 @@
3.3.4
3.3.5

10
Aptfile
View File

@ -1,5 +1,5 @@
ffmpeg
libopenblas0-pthread
libpq-dev
libxdamage1
libxfixes3
libidn12
# for idn-ruby on heroku-24 stack
# use https://github.com/heroku/heroku-buildpack-activestorage-preview
# in place for ffmpeg and its dependent packages to reduce slag size

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,34 +167,43 @@ 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 reand and keeping maximum compatibility across mail clients.
All emails to end-users have been completely redesigned with a fresh new look, providing more information while making them easier to read and keeping maximum compatibility across mail clients.
- **Change follow recommendations algorithm** (#28314, #28433, #29017, #29108, #29306, #29550, #29619, and #31474 by @ClearlyClaire, @Gargron, @kernal053, @mjankowski, and @wheatear-dev)\
This replaces the “past interactions” recommendation algorithm with a “friends of friends” algorithm that suggests accounts followed by people you follow, and a “similar profiles” algorithm that suggests accounts with a profile similar to your most recent follows.\
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

@ -12,7 +12,7 @@ ARG BUILDPLATFORM=${BUILDPLATFORM}
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.3.x"]
# renovate: datasource=docker depName=docker.io/ruby
ARG RUBY_VERSION="3.3.4"
ARG RUBY_VERSION="3.3.5"
# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
# renovate: datasource=node-version depName=node
ARG NODE_MAJOR_VERSION="20"

16
Gemfile
View File

@ -47,7 +47,6 @@ gem 'color_diff', '~> 0.1'
gem 'csv', '~> 3.2'
gem 'discard', '~> 1.2'
gem 'doorkeeper', '~> 5.6'
gem 'ed25519', '~> 1.3'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'hiredis', '~> 0.6'
@ -99,10 +98,10 @@ gem 'json-ld'
gem 'json-ld-preloaded', '~> 3.2'
gem 'rdf-normalize', '~> 0.5'
gem 'opentelemetry-api', '~> 1.3.0'
gem 'opentelemetry-api', '~> 1.4.0'
group :opentelemetry do
gem 'opentelemetry-exporter-otlp', '~> 0.28.0', require: false
gem 'opentelemetry-exporter-otlp', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-active_job', '~> 0.7.1', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.20.1', require: false
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.21.2', require: false
@ -111,7 +110,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
@ -126,9 +125,6 @@ group :test do
# Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab
gem 'rspec-github', '~> 2.4', require: false
# RSpec progress bar formatter
gem 'fuubar', '~> 2.5'
# RSpec helpers for email specs
gem 'email_spec'
@ -149,11 +145,13 @@ group :test do
gem 'rails-controller-testing', '~> 1.0'
# Validate schemas in specs
gem 'json-schema', '~> 4.0'
gem 'json-schema', '~> 5.0'
# Test harness fo rack components
gem 'rack-test', '~> 2.1'
gem 'shoulda-matchers'
# Coverage formatter for RSpec test if DISABLE_SIMPLECOV is false
gem 'simplecov', '~> 0.22', require: false
gem 'simplecov-lcov', '~> 0.8', require: false
@ -210,7 +208,7 @@ group :development, :test do
gem 'test-prof'
# RSpec runner for rails
gem 'rspec-rails', '~> 6.0'
gem 'rspec-rails', '~> 7.0'
end
group :production do

View File

@ -10,35 +10,35 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.1.3.4)
actionpack (= 7.1.3.4)
activesupport (= 7.1.3.4)
actioncable (7.1.4)
actionpack (= 7.1.4)
activesupport (= 7.1.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.1.3.4)
actionpack (= 7.1.3.4)
activejob (= 7.1.3.4)
activerecord (= 7.1.3.4)
activestorage (= 7.1.3.4)
activesupport (= 7.1.3.4)
actionmailbox (7.1.4)
actionpack (= 7.1.4)
activejob (= 7.1.4)
activerecord (= 7.1.4)
activestorage (= 7.1.4)
activesupport (= 7.1.4)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.1.3.4)
actionpack (= 7.1.3.4)
actionview (= 7.1.3.4)
activejob (= 7.1.3.4)
activesupport (= 7.1.3.4)
actionmailer (7.1.4)
actionpack (= 7.1.4)
actionview (= 7.1.4)
activejob (= 7.1.4)
activesupport (= 7.1.4)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.2)
actionpack (7.1.3.4)
actionview (= 7.1.3.4)
activesupport (= 7.1.3.4)
actionpack (7.1.4)
actionview (= 7.1.4)
activesupport (= 7.1.4)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
@ -46,15 +46,15 @@ GEM
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
actiontext (7.1.3.4)
actionpack (= 7.1.3.4)
activerecord (= 7.1.3.4)
activestorage (= 7.1.3.4)
activesupport (= 7.1.3.4)
actiontext (7.1.4)
actionpack (= 7.1.4)
activerecord (= 7.1.4)
activestorage (= 7.1.4)
activesupport (= 7.1.4)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.1.3.4)
activesupport (= 7.1.3.4)
actionview (7.1.4)
activesupport (= 7.1.4)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
@ -64,22 +64,22 @@ GEM
activemodel (>= 4.1)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (7.1.3.4)
activesupport (= 7.1.3.4)
activejob (7.1.4)
activesupport (= 7.1.4)
globalid (>= 0.3.6)
activemodel (7.1.3.4)
activesupport (= 7.1.3.4)
activerecord (7.1.3.4)
activemodel (= 7.1.3.4)
activesupport (= 7.1.3.4)
activemodel (7.1.4)
activesupport (= 7.1.4)
activerecord (7.1.4)
activemodel (= 7.1.4)
activesupport (= 7.1.4)
timeout (>= 0.4.0)
activestorage (7.1.3.4)
actionpack (= 7.1.3.4)
activejob (= 7.1.3.4)
activerecord (= 7.1.3.4)
activesupport (= 7.1.3.4)
activestorage (7.1.4)
actionpack (= 7.1.4)
activejob (= 7.1.4)
activerecord (= 7.1.4)
activesupport (= 7.1.4)
marcel (~> 1.0)
activesupport (7.1.3.4)
activesupport (7.1.4)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
@ -100,20 +100,20 @@ GEM
attr_required (1.0.2)
awrence (1.2.1)
aws-eventstream (1.3.0)
aws-partitions (1.966.0)
aws-sdk-core (3.201.5)
aws-partitions (1.978.0)
aws-sdk-core (3.209.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.88.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (1.94.0)
aws-sdk-core (~> 3, >= 3.207.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.159.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-s3 (1.166.0)
aws-sdk-core (~> 3, >= 3.207.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.9.1)
aws-sigv4 (1.10.0)
aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
@ -137,14 +137,14 @@ GEM
blurhash (0.1.7)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (6.1.2)
brakeman (6.2.1)
racc
browser (5.3.1)
brpoplpush-redis_script (0.1.3)
concurrent-ruby (~> 1.0, >= 1.0.5)
redis (>= 1.0, < 6)
builder (3.3.0)
bundler-audit (0.9.1)
bundler-audit (0.9.2)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
capybara (3.40.0)
@ -164,20 +164,22 @@ GEM
activesupport (>= 5.2)
elasticsearch (>= 7.14.0, < 8)
elasticsearch-dsl
childprocess (5.1.0)
logger (~> 1.5)
chunky_png (1.4.0)
climate_control (1.2.0)
cocoon (1.2.15)
color_diff (0.1)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
cose (1.3.0)
cose (1.3.1)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
css_parser (1.17.1)
css_parser (1.19.0)
addressable
csv (3.3.0)
database_cleaner-active_record (2.2.0)
@ -195,7 +197,7 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-two-factor (5.1.0)
devise-two-factor (6.0.0)
activesupport (~> 7.0)
devise (~> 4.0)
railties (~> 7.0)
@ -206,20 +208,20 @@ GEM
diff-lcs (1.5.1)
discard (1.3.0)
activerecord (>= 4.2, < 8)
docile (1.4.0)
docile (1.4.1)
domain_name (0.6.20240107)
doorkeeper (5.7.1)
railties (>= 5)
dotenv (3.1.2)
dotenv (3.1.4)
drb (2.2.1)
ed25519 (1.3.0)
elasticsearch (7.17.10)
elasticsearch-api (= 7.17.10)
elasticsearch-transport (= 7.17.10)
elasticsearch-api (7.17.10)
elasticsearch (7.17.11)
elasticsearch-api (= 7.17.11)
elasticsearch-transport (= 7.17.11)
elasticsearch-api (7.17.11)
multi_json
elasticsearch-dsl (0.1.10)
elasticsearch-transport (7.17.10)
elasticsearch-transport (7.17.11)
base64
faraday (>= 1, < 3)
multi_json
email_spec (2.3.0)
@ -251,7 +253,7 @@ GEM
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
@ -264,10 +266,11 @@ GEM
ffi-compiler (1.3.2)
ffi (>= 1.15.5)
rake
flatware (2.3.2)
flatware (2.3.3)
drb
thor (< 2.0)
flatware-rspec (2.3.2)
flatware (= 2.3.2)
flatware-rspec (2.3.3)
flatware (= 2.3.3)
rspec (>= 3.6)
fog-core (2.5.0)
builder
@ -284,14 +287,11 @@ GEM
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
fuubar (2.5.1)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
google-protobuf (3.25.4)
googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18)
google-protobuf (3.25.5)
googleapis-common-protos-types (1.15.0)
google-protobuf (>= 3.18, < 5.a)
haml (6.3.0)
temple (>= 0.8.2)
thor
@ -307,11 +307,12 @@ GEM
rainbow
rubocop (>= 1.0)
sysexits (~> 1.1)
hashdiff (1.1.0)
hashdiff (1.1.1)
hashie (5.0.0)
hcaptcha (7.1.0)
json
highline (3.0.1)
highline (3.1.1)
reline
hiredis (0.6.3)
hkdf (0.3.0)
htmlentities (4.3.4)
@ -329,7 +330,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)
@ -342,7 +343,7 @@ GEM
rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1)
idn-ruby (0.1.5)
inline_svg (1.9.0)
inline_svg (1.10.0)
activesupport (>= 3.0)
nokogiri (>= 1.6)
io-console (0.7.2)
@ -368,8 +369,8 @@ GEM
json-ld-preloaded (3.3.0)
json-ld (~> 3.3)
rdf (~> 3.3)
json-schema (4.3.1)
addressable (>= 2.8)
json-schema (5.0.0)
addressable (~> 2.8)
jsonapi-renderer (0.2.2)
jwt (2.7.1)
kaminari (1.2.2)
@ -391,8 +392,9 @@ GEM
mime-types
terrapin (>= 0.6.0, < 2.0)
language_server-protocol (3.17.0.3)
launchy (2.5.2)
launchy (3.0.1)
addressable (~> 2.8)
childprocess (~> 5.0)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
letter_opener_web (3.0.0)
@ -404,7 +406,7 @@ GEM
llhttp-ffi (0.5.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
logger (1.6.0)
logger (1.6.1)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
@ -426,22 +428,22 @@ GEM
addressable (~> 2.5)
azure-storage-blob (~> 2.0.1)
hashie (~> 5.0)
memory_profiler (1.0.2)
memory_profiler (1.1.0)
mime-types (3.5.2)
mime-types-data (~> 3.2015)
mime-types-data (3.2024.0702)
mime-types-data (3.2024.0820)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitest (5.24.1)
minitest (5.25.1)
msgpack (1.7.2)
multi_json (1.15.0)
multipart-post (2.4.0)
multipart-post (2.4.1)
mutex_m (0.2.0)
net-http (0.4.1)
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.14)
net-imap (0.4.15)
date
net-protocol
net-ldap (0.19.0)
@ -455,7 +457,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)
@ -466,12 +468,12 @@ GEM
addressable (~> 2.8)
nokogiri (~> 1.12)
omniauth (~> 2.1)
omniauth-rails_csrf_protection (1.0.1)
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)
@ -489,18 +491,18 @@ GEM
openssl (3.2.0)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
opentelemetry-api (1.3.0)
opentelemetry-common (0.20.1)
opentelemetry-api (1.4.0)
opentelemetry-common (0.21.0)
opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.28.1)
opentelemetry-exporter-otlp (0.29.0)
google-protobuf (>= 3.18)
googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1)
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)
@ -525,8 +527,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)
@ -546,7 +549,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)
@ -580,25 +583,25 @@ GEM
orm_adapter (0.5.0)
ostruct (0.6.0)
ox (2.14.18)
parallel (1.25.1)
parser (3.3.4.0)
parallel (1.26.3)
parser (3.3.5.0)
ast (~> 2.4.1)
racc
parslet (2.0.0)
pastel (0.8.0)
tty-color (~> 0.5)
pg (1.5.7)
pg (1.5.8)
pghero (3.6.0)
activerecord (>= 6.1)
premailer (1.23.0)
premailer (1.27.0)
addressable
css_parser (>= 1.12.0)
css_parser (>= 1.19.0)
htmlentities (>= 4.0.0)
premailer-rails (1.12.0)
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
@ -606,9 +609,9 @@ GEM
psych (5.1.2)
stringio
public_suffix (6.0.1)
puma (6.4.2)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.2)
pundit (2.4.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
@ -635,20 +638,20 @@ GEM
rackup (1.0.0)
rack (< 3)
webrick
rails (7.1.3.4)
actioncable (= 7.1.3.4)
actionmailbox (= 7.1.3.4)
actionmailer (= 7.1.3.4)
actionpack (= 7.1.3.4)
actiontext (= 7.1.3.4)
actionview (= 7.1.3.4)
activejob (= 7.1.3.4)
activemodel (= 7.1.3.4)
activerecord (= 7.1.3.4)
activestorage (= 7.1.3.4)
activesupport (= 7.1.3.4)
rails (7.1.4)
actioncable (= 7.1.4)
actionmailbox (= 7.1.4)
actionmailer (= 7.1.4)
actionpack (= 7.1.4)
actiontext (= 7.1.4)
actionview (= 7.1.4)
activejob (= 7.1.4)
activemodel (= 7.1.4)
activerecord (= 7.1.4)
activestorage (= 7.1.4)
activesupport (= 7.1.4)
bundler (>= 1.15.0)
railties (= 7.1.3.4)
railties (= 7.1.4)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
@ -663,9 +666,9 @@ GEM
rails-i18n (7.0.9)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.1.3.4)
actionpack (= 7.1.3.4)
activesupport (= 7.1.3.4)
railties (7.1.4)
actionpack (= 7.1.4)
activesupport (= 7.1.4)
irb
rackup (>= 1.0.0)
rake (>= 12.2)
@ -688,17 +691,16 @@ 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)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
rexml (3.3.4)
strscan
rexml (3.3.7)
rotp (6.3.0)
rouge (4.2.1)
rouge (4.3.0)
rpam2 (4.0.2)
rqrcode (2.2.0)
chunky_png (~> 1.0)
@ -708,9 +710,9 @@ GEM
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.0)
rspec-core (3.13.1)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.1)
rspec-expectations (3.13.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-github (2.4.0)
@ -718,10 +720,10 @@ GEM
rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (6.1.4)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
rspec-rails (7.0.1)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
@ -732,18 +734,17 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8)
rspec-support (3.13.1)
rubocop (1.65.1)
rubocop (1.66.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.4, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-ast (>= 1.32.2, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.3)
rubocop-ast (1.32.3)
parser (>= 3.3.1.0)
rubocop-capybara (2.21.0)
rubocop (~> 1.41)
@ -762,7 +763,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)
@ -780,13 +781,15 @@ GEM
scenic (1.8.0)
activerecord (>= 4.0.0)
railties (>= 4.0.0)
selenium-webdriver (4.23.0)
selenium-webdriver (4.25.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
semantic_range (3.0.0)
shoulda-matchers (6.4.0)
activesupport (>= 5.2.0)
sidekiq (6.5.12)
connection_pool (>= 2.2.5, < 3)
rack (~> 2.0)
@ -821,7 +824,6 @@ GEM
stringio (3.1.1)
strong_migrations (2.0.0)
activerecord (>= 6.1)
strscan (3.1.0)
swd (1.3.0)
activesupport (>= 3)
attr_required (>= 0.0.5)
@ -832,11 +834,11 @@ GEM
unicode-display_width (>= 1.1.1, < 3)
terrapin (1.0.1)
climate_control
test-prof (1.4.0)
thor (1.3.1)
tilt (2.3.0)
test-prof (1.4.2)
thor (1.3.2)
tilt (2.4.0)
timeout (0.4.1)
tpm-key_attestation (0.12.0)
tpm-key_attestation (0.12.1)
bindata (~> 2.4)
openssl (> 2.0)
openssl-signature_algorithm (~> 1.0)
@ -855,13 +857,13 @@ GEM
unf (~> 0.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2024.1)
tzinfo-data (1.2024.2)
tzinfo (>= 1.0.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.9.1)
unicode-display_width (2.5.0)
uri (0.13.0)
uri (0.13.1)
validate_email (0.1.6)
activemodel (>= 3.0)
mail (>= 2.2.5)
@ -891,7 +893,7 @@ GEM
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webrick (1.8.1)
webrick (1.8.2)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
@ -900,7 +902,7 @@ GEM
xorcist (1.1.3)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.17)
zeitwerk (2.6.18)
PLATFORMS
ruby
@ -934,7 +936,6 @@ DEPENDENCIES
discard (~> 1.2)
doorkeeper (~> 5.6)
dotenv
ed25519 (~> 1.3)
email_spec
fabrication (~> 2.30)
faker (~> 3.2)
@ -943,7 +944,6 @@ DEPENDENCIES
flatware-rspec
fog-core (<= 2.5.0)
fog-openstack (~> 1.0)
fuubar (~> 2.5)
haml-rails (~> 2.0)
haml_lint
hcaptcha (~> 7.1)
@ -959,7 +959,7 @@ DEPENDENCIES
irb (~> 1.8)
json-ld
json-ld-preloaded (~> 3.2)
json-schema (~> 4.0)
json-schema (~> 5.0)
kaminari (~> 1.2)
kt-paperclip (~> 7.2)
letter_opener (~> 1.8)
@ -980,8 +980,8 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 1.0)
omniauth-saml (~> 2.0)
omniauth_openid_connect (~> 0.6.1)
opentelemetry-api (~> 1.3.0)
opentelemetry-exporter-otlp (~> 0.28.0)
opentelemetry-api (~> 1.4.0)
opentelemetry-exporter-otlp (~> 0.29.0)
opentelemetry-instrumentation-active_job (~> 0.7.1)
opentelemetry-instrumentation-active_model_serializers (~> 0.20.1)
opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2)
@ -990,7 +990,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)
@ -1018,7 +1018,7 @@ DEPENDENCIES
redis-namespace (~> 1.10)
rqrcode (~> 2.2)
rspec-github (~> 2.4)
rspec-rails (~> 6.0)
rspec-rails (~> 7.0)
rspec-sidekiq (~> 5.0)
rubocop
rubocop-capybara
@ -1033,6 +1033,7 @@ DEPENDENCIES
sanitize (~> 6.0)
scenic (~> 1.7)
selenium-webdriver
shoulda-matchers
sidekiq (~> 6.5)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 5.0)
@ -1056,7 +1057,7 @@ DEPENDENCIES
xorcist (~> 1.1)
RUBY VERSION
ruby 3.3.2p78
ruby 3.3.4p94
BUNDLED WITH
2.5.11
2.5.18

View File

@ -11,4 +11,4 @@ worker: bundle exec sidekiq
#
# and let the main app use the separate app:
#
# heroku config:set STREAMING_API_BASE_URL=wss://<streaming-app>.herokuapp.com -a <main-app>
# heroku config:set STREAMING_API_BASE_URL=wss://<streaming-app-random>.herokuapp.com -a <main-app>

View File

@ -90,9 +90,15 @@
}
},
"buildpacks": [
{
"url": "https://github.com/heroku/heroku-buildpack-activestorage-preview"
},
{
"url": "https://github.com/heroku/heroku-buildpack-apt"
},
{
"url": "heroku/nodejs"
},
{
"url": "heroku/ruby"
}
@ -100,5 +106,6 @@
"scripts": {
"postdeploy": "bundle exec rails db:migrate && bundle exec rails db:seed"
},
"addons": ["heroku-postgresql", "heroku-redis"]
"addons": ["heroku-postgresql", "heroku-redis"],
"stack": "heroku-24"
}

View File

@ -1,18 +0,0 @@
# frozen_string_literal: true
class ActivityPub::ClaimsController < ActivityPub::BaseController
skip_before_action :authenticate_user!
before_action :require_account_signature!
before_action :set_claim_result
def create
render json: @claim_result, serializer: ActivityPub::OneTimeKeySerializer
end
private
def set_claim_result
@claim_result = ::Keys::ClaimService.new.call(@account.id, params[:id])
end
end

View File

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

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
class ActivityPub::LikesController < ActivityPub::BaseController
include Authorization
vary_by -> { 'Signature' if authorized_fetch_mode? }
before_action :require_account_signature!, if: :authorized_fetch_mode?
before_action :set_status
def index
expires_in 0, public: @status.distributable? && public_fetch_mode?
render json: likes_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
private
def pundit_user
signed_request_account
end
def set_status
@status = @account.statuses.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
def likes_collection_presenter
ActivityPub::CollectionPresenter.new(
id: account_status_likes_url(@account, @status),
type: :unordered,
size: @status.favourites_count
)
end
end

View File

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

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
class ActivityPub::SharesController < ActivityPub::BaseController
include Authorization
vary_by -> { 'Signature' if authorized_fetch_mode? }
before_action :require_account_signature!, if: :authorized_fetch_mode?
before_action :set_status
def index
expires_in 0, public: @status.distributable? && public_fetch_mode?
render json: shares_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
private
def pundit_user
signed_request_account
end
def set_status
@status = @account.statuses.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
def shares_collection_presenter
ActivityPub::CollectionPresenter.new(
id: account_status_shares_url(@account, @status),
type: :unordered,
size: @status.reblogs_count
)
end
end

View File

@ -13,7 +13,7 @@ module Admin
redirect_to admin_account_path(@account_moderation_note.target_account_id), notice: I18n.t('admin.account_moderation_notes.created_msg')
else
@account = @account_moderation_note.target_account
@moderation_notes = @account.targeted_moderation_notes.latest
@moderation_notes = @account.targeted_moderation_notes.chronological.includes(:account)
@warnings = @account.strikes.custom.latest
render 'admin/accounts/show'

View File

@ -33,7 +33,7 @@ module Admin
@deletion_request = @account.deletion_request
@account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
@moderation_notes = @account.targeted_moderation_notes.latest
@moderation_notes = @account.targeted_moderation_notes.chronological.includes(:account)
@warnings = @account.strikes.includes(:target_account, :account, :appeal).latest
@domain_block = DomainBlock.rule_for(@account.domain)
end

View File

@ -7,17 +7,12 @@ module Admin
layout 'admin'
before_action :set_body_classes
before_action :set_cache_headers
after_action :verify_authorized
private
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end

View File

@ -7,12 +7,12 @@ module Admin
def index
authorize :dashboard, :index?
@pending_appeals_count = Appeal.pending.async_count
@pending_reports_count = Report.unresolved.async_count
@pending_tags_count = Tag.pending_review.async_count
@pending_users_count = User.pending.async_count
@system_checks = Admin::SystemCheck.perform(current_user)
@time_period = (29.days.ago.to_date...Time.now.utc.to_date)
@pending_users_count = User.pending.count
@pending_reports_count = Report.unresolved.count
@pending_tags_count = Tag.pending_review.count
@pending_appeals_count = Appeal.pending.count
end
end
end

View File

@ -21,7 +21,7 @@ module Admin
redirect_to after_create_redirect_path, notice: I18n.t('admin.report_notes.created_msg')
else
@report_notes = @report.notes.includes(:account).order(id: :desc)
@report_notes = @report.notes.chronological.includes(:account)
@action_logs = @report.history.includes(:target)
@form = Admin::StatusBatchAction.new
@statuses = @report.statuses.with_includes

View File

@ -13,7 +13,7 @@ module Admin
authorize @report, :show?
@report_note = @report.notes.new
@report_notes = @report.notes.includes(:account).order(id: :desc)
@report_notes = @report.notes.chronological.includes(:account)
@action_logs = @report.history.includes(:target)
@form = Admin::StatusBatchAction.new
@statuses = @report.statuses.with_includes

View File

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

View File

@ -1,30 +0,0 @@
# frozen_string_literal: true
class Api::V1::Crypto::DeliveriesController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_current_device
def create
devices.each do |device_params|
DeliverToDeviceService.new.call(current_account, @current_device, device_params)
end
render_empty
end
private
def set_current_device
@current_device = Device.find_by!(access_token: doorkeeper_token)
end
def resource_params
params.require(:device)
params.permit(device: [:account_id, :device_id, :type, :body, :hmac])
end
def devices
Array(resource_params[:device])
end
end

View File

@ -1,47 +0,0 @@
# frozen_string_literal: true
class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController
LIMIT = 80
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_current_device
before_action :set_encrypted_messages, only: :index
after_action :insert_pagination_headers, only: :index
def index
render json: @encrypted_messages, each_serializer: REST::EncryptedMessageSerializer
end
def clear
@current_device.encrypted_messages.up_to(params[:up_to_id]).delete_all
render_empty
end
private
def set_current_device
@current_device = Device.find_by!(access_token: doorkeeper_token)
end
def set_encrypted_messages
@encrypted_messages = @current_device.encrypted_messages.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
end
def next_path
api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty?
end
def pagination_collection
@encrypted_messages
end
def records_continue?
@encrypted_messages.size == limit_param(LIMIT)
end
end

View File

@ -1,25 +0,0 @@
# frozen_string_literal: true
class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_claim_results
def create
render json: @claim_results, each_serializer: REST::Keys::ClaimResultSerializer
end
private
def set_claim_results
@claim_results = devices.filter_map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) }
end
def resource_params
params.permit(device: [:account_id, :device_id])
end
def devices
Array(resource_params[:device])
end
end

View File

@ -1,17 +0,0 @@
# frozen_string_literal: true
class Api::V1::Crypto::Keys::CountsController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_current_device
def show
render json: { one_time_keys: @current_device.one_time_keys.count }
end
private
def set_current_device
@current_device = Device.find_by!(access_token: doorkeeper_token)
end
end

View File

@ -1,26 +0,0 @@
# frozen_string_literal: true
class Api::V1::Crypto::Keys::QueriesController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_accounts
before_action :set_query_results
def create
render json: @query_results, each_serializer: REST::Keys::QueryResultSerializer
end
private
def set_accounts
@accounts = Account.where(id: account_ids).includes(:devices)
end
def set_query_results
@query_results = @accounts.filter_map { |account| ::Keys::QueryService.new.call(account) }
end
def account_ids
Array(params[:id]).map(&:to_i)
end
end

View File

@ -1,29 +0,0 @@
# frozen_string_literal: true
class Api::V1::Crypto::Keys::UploadsController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
def create
device = Device.find_or_initialize_by(access_token: doorkeeper_token)
device.transaction do
device.account = current_account
device.update!(resource_params[:device])
if resource_params[:one_time_keys].present? && resource_params[:one_time_keys].is_a?(Enumerable)
resource_params[:one_time_keys].each do |one_time_key_params|
device.one_time_keys.create!(one_time_key_params)
end
end
end
render json: device, serializer: REST::Keys::DeviceSerializer
end
private
def resource_params
params.permit(device: [:device_id, :name, :fingerprint_key, :identity_key], one_time_keys: [:key_id, :key, :signature])
end
end

View File

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

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
class Api::V2::Notifications::AccountsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }
before_action :require_user!
before_action :set_notifications!
after_action :insert_pagination_headers, only: :index
def index
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
private
def load_accounts
@paginated_notifications.map(&:from_account)
end
def set_notifications!
@paginated_notifications = begin
current_account
.notifications
.without_suspended
.where(group_key: params[:notification_group_key])
.includes(from_account: [:account_stat, :user])
.paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params[:max_id],
params[:since_id]
)
end
end
def next_path
api_v2_notification_accounts_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
api_v2_notification_accounts_url pagination_params(min_id: pagination_since_id) unless @paginated_notifications.empty?
end
def pagination_collection
@paginated_notifications
end
def records_continue?
@paginated_notifications.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
end

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!
@ -13,7 +13,6 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
def index
with_read_replica do
@notifications = load_notifications
@group_metadata = load_group_metadata
@grouped_notifications = load_grouped_notifications
@relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
@presenter = GroupedNotificationsPresenter.new(@grouped_notifications, expand_accounts: expand_accounts_param)
@ -22,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(
@ -34,7 +33,7 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
'app.notification_grouping.expand_accounts_param' => expand_accounts_param
)
render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata, expand_accounts: expand_accounts_param
render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param
end
end
@ -42,13 +41,13 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
limit = limit_param(DEFAULT_NOTIFICATIONS_COUNT_LIMIT, MAX_NOTIFICATIONS_COUNT_LIMIT)
with_read_replica do
render json: { count: browserable_account_notifications.paginate_groups_by_min_id(limit, min_id: notification_marker&.last_read_id).count }
render json: { count: browserable_account_notifications.paginate_groups_by_min_id(limit, min_id: notification_marker&.last_read_id, grouped_types: params[:grouped_types]).count }
end
end
def show
@notification = current_account.notifications.without_suspended.find_by!(group_key: params[:id])
presenter = GroupedNotificationsPresenter.new([NotificationGroup.from_notification(@notification)])
@notification = current_account.notifications.without_suspended.find_by!(group_key: params[:group_key])
presenter = GroupedNotificationsPresenter.new(NotificationGroup.from_notifications([@notification]))
render json: presenter, serializer: REST::DedupNotificationGroupSerializer
end
@ -58,17 +57,17 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
end
def dismiss
current_account.notifications.where(group_key: params[:id]).destroy_all
current_account.notifications.where(group_key: params[:group_key]).destroy_all
render_empty
end
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)
params.slice(:max_id, :since_id, :min_id, :grouped_types).permit(:max_id, :since_id, :min_id, grouped_types: [])
)
Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses|
@ -77,22 +76,11 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
end
end
def load_group_metadata
return {} if @notifications.empty?
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_group_metadata') do
browserable_account_notifications
.where(group_key: @notifications.filter_map(&:group_key))
.where(id: (@notifications.last.id)..(@notifications.first.id))
.group(:group_key)
.pluck(:group_key, 'min(notifications.id) as min_id', 'max(notifications.id) as max_id', 'max(notifications.created_at) as latest_notification_at')
.to_h { |group_key, min_id, max_id, latest_notification_at| [group_key, { min_id: min_id, max_id: max_id, latest_notification_at: latest_notification_at }] }
end
end
def load_grouped_notifications
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_grouped_notifications') do
@notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }
return [] if @notifications.empty?
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
@ -113,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
@ -125,11 +113,11 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
end
def browserable_params
params.permit(:include_filtered, types: [], exclude_types: [])
params.slice(:include_filtered, :types, :exclude_types, :grouped_types).permit(:include_filtered, types: [], exclude_types: [], grouped_types: [])
end
def pagination_params(core_params)
params.slice(:limit, :types, :exclude_types, :include_filtered).permit(:limit, :include_filtered, types: [], exclude_types: []).merge(core_params)
params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types).permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: []).merge(core_params)
end
def expand_accounts_param

View File

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

View File

@ -11,7 +11,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :configure_sign_up_params, only: [:create]
before_action :set_sessions, only: [:edit, :update]
before_action :set_strikes, only: [:edit, :update]
before_action :set_body_classes, only: [:new, :create, :edit, :update]
before_action :require_not_suspended!, only: [:update]
before_action :set_cache_headers, only: [:edit, :update]
before_action :set_rules, only: :new
@ -104,10 +103,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
private
def set_body_classes
@body_classes = 'admin' if %w(edit update).include?(action_name)
end
def set_invite
@invite = begin
invite = Invite.find_by(code: invite_code) if invite_code.present?

View File

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

View File

@ -20,7 +20,7 @@ module AccountControllerConcern
webfinger_account_link,
actor_url_link,
]
)
).to_s
end
def webfinger_account_link

View File

@ -19,7 +19,7 @@ module Api::Pagination
links = []
links << [next_path, [%w(rel next)]] if next_path
links << [prev_path, [%w(rel prev)]] if prev_path
response.headers['Link'] = LinkHeader.new(links) unless links.empty?
response.headers['Link'] = LinkHeader.new(links).to_s unless links.empty?
end
def require_valid_pagination_options!

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

View File

@ -7,16 +7,11 @@ class Disputes::BaseController < ApplicationController
skip_before_action :require_functional!
before_action :set_body_classes
before_action :authenticate_user!
before_action :set_cache_headers
private
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end

View File

@ -6,7 +6,6 @@ class Filters::StatusesController < ApplicationController
before_action :authenticate_user!
before_action :set_filter
before_action :set_status_filters
before_action :set_body_classes
before_action :set_cache_headers
PER_PAGE = 20
@ -42,10 +41,6 @@ class Filters::StatusesController < ApplicationController
'remove' if params[:remove]
end
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end

View File

@ -5,7 +5,6 @@ class FiltersController < ApplicationController
before_action :authenticate_user!
before_action :set_filter, only: [:edit, :update, :destroy]
before_action :set_body_classes
before_action :set_cache_headers
def index
@ -52,10 +51,6 @@ class FiltersController < ApplicationController
params.require(:custom_filter).permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy])
end
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end

View File

@ -6,7 +6,6 @@ class InvitesController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_body_classes
before_action :set_cache_headers
def index
@ -47,10 +46,6 @@ class InvitesController < ApplicationController
params.require(:invite).permit(:max_uses, :expires_in, :autofollow, :comment)
end
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end

View File

@ -19,9 +19,7 @@ class MediaController < ApplicationController
redirect_to @media_attachment.file.url(:original)
end
def player
@body_classes = 'player'
end
def player; end
private

View File

@ -6,7 +6,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
before_action :store_current_location
before_action :authenticate_resource_owner!
before_action :require_not_suspended!, only: :destroy
before_action :set_body_classes
before_action :set_cache_headers
before_action :set_last_used_at_by_app, only: :index, unless: -> { request.format == :json }
@ -23,10 +22,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
private
def set_body_classes
@body_classes = 'admin'
end
def store_current_location
store_location_for(:user, request.url)
end

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

@ -6,7 +6,6 @@ class RelationshipsController < ApplicationController
before_action :authenticate_user!
before_action :set_accounts, only: :show
before_action :set_relationships, only: :show
before_action :set_body_classes
before_action :set_cache_headers
helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship?
@ -68,10 +67,6 @@ class RelationshipsController < ApplicationController
end
end
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end

View File

@ -4,15 +4,10 @@ class Settings::BaseController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_body_classes
before_action :set_cache_headers
private
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
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

@ -4,7 +4,6 @@ class SeveredRelationshipsController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_body_classes
before_action :set_cache_headers
before_action :set_event, only: [:following, :followers]
@ -51,10 +50,6 @@ class SeveredRelationshipsController < ApplicationController
account.local? ? account.local_username_and_domain : account.acct
end
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end

View File

@ -4,13 +4,6 @@ class SharesController < ApplicationController
layout 'modal'
before_action :authenticate_user!
before_action :set_body_classes
def show; end
private
def set_body_classes
@body_classes = 'modal-layout compose-standalone'
end
end

View File

@ -5,7 +5,6 @@ class StatusesCleanupController < ApplicationController
before_action :authenticate_user!
before_action :set_policy
before_action :set_body_classes
before_action :set_cache_headers
def show; end
@ -34,10 +33,6 @@ class StatusesCleanupController < ApplicationController
params.require(:account_statuses_cleanup_policy).permit(:enabled, :min_status_age, :keep_direct, :keep_pinned, :keep_polls, :keep_media, :keep_self_fav, :keep_self_bookmark, :min_favs, :min_reblogs)
end
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
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,12 +50,10 @@ 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)]]])
response.headers['Link'] = LinkHeader.new(
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]
).to_s
end
def set_status

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

@ -5,7 +5,7 @@ module Admin::Trends::StatusesHelper
text = if status.local?
status.text.split("\n").first
else
Nokogiri::HTML(status.text).css('html > body > *').first&.text
Nokogiri::HTML5(status.text).css('html > body > *').first&.text
end
return '' if text.blank?

View File

@ -86,7 +86,7 @@ module ApplicationHelper
def html_title
safe_join(
[content_for(:page_title).to_s.chomp, title]
.select(&:present?),
.compact_blank,
' - '
)
end
@ -106,11 +106,16 @@ module ApplicationHelper
end
def material_symbol(icon, attributes = {})
safe_join(
[
inline_svg_tag(
"400-24px/#{icon}.svg",
class: ['icon', "material-#{icon}"].concat(attributes[:class].to_s.split),
role: :img,
data: attributes[:data]
),
' ',
]
)
end
@ -154,6 +159,7 @@ module ApplicationHelper
def body_classes
output = body_class_string.split
output << content_for(:body_classes)
output << "flavour-#{current_flavour.parameterize}"
output << "skin-#{current_skin.parameterize}"
output << 'system-font' if current_account&.user&.setting_system_font_ui

View File

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

View File

@ -200,14 +200,6 @@ module JsonLdHelper
nil
end
def merge_context(context, new_context)
if context.is_a?(Array)
context << new_context
else
[context, new_context]
end
end
def response_successful?(response)
(200...300).cover?(response.code)
end

View File

@ -238,9 +238,7 @@ module LanguagesHelper
# Helper for self.sorted_locale_keys
private_class_method def self.locale_name_for_sorting(locale)
if locale.blank? || locale == 'und'
'000'
elsif (supported_locale = SUPPORTED_LOCALES[locale.to_sym])
if (supported_locale = SUPPORTED_LOCALES[locale.to_sym])
ASCIIFolding.new.fold(supported_locale[1]).downcase
elsif (regional_locale = REGIONAL_LOCALE_NAMES[locale.to_sym])
ASCIIFolding.new.fold(regional_locale).downcase

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

@ -6,5 +6,4 @@ export const submitAccountNote = createDataLoadingThunk(
({ accountId, note }: { accountId: string; note: string }) =>
apiSubmitAccountNote(accountId, note),
(relationship) => ({ relationship }),
{ skipLoading: true },
);

View File

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

View File

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

View File

@ -1,12 +0,0 @@
import { saveSettings } from './settings';
export const LANGUAGE_USE = 'LANGUAGE_USE';
export const useLanguage = language => dispatch => {
dispatch({
type: LANGUAGE_USE,
language,
});
dispatch(saveSettings());
};

View File

@ -64,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
}
},
@ -75,17 +75,7 @@ interface MarkerParam {
}
function getLastNotificationId(state: RootState): string | undefined {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const enableBeta = state.settings.getIn(
['notifications', 'groupingBeta'],
false,
) as boolean;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return enableBeta
? 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 {
@ -18,7 +18,7 @@ import {
selectSettingsNotificationsQuickFilterActive,
selectSettingsNotificationsShows,
} from 'flavours/glitch/selectors/settings';
import type { AppDispatch } from 'flavours/glitch/store';
import type { AppDispatch, RootState } from 'flavours/glitch/store';
import {
createAppAsyncThunk,
createDataLoadingThunk,
@ -32,6 +32,14 @@ function excludeAllTypesExcept(filter: string) {
return allNotificationTypes.filter((item) => item !== filter);
}
function getExcludedTypes(state: RootState) {
const activeFilter = selectSettingsNotificationsQuickFilterActive(state);
return activeFilter === 'all'
? selectSettingsNotificationsExcludedTypes(state)
: excludeAllTypesExcept(activeFilter);
}
function dispatchAssociatedRecords(
dispatch: AppDispatch,
notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[],
@ -60,19 +68,15 @@ function dispatchAssociatedRecords(
dispatch(importFetchedStatuses(fetchedStatuses));
}
const supportedGroupedNotificationTypes = ['favourite', 'reblog'];
export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch',
async (_params, { getState }) => {
const activeFilter =
selectSettingsNotificationsQuickFilterActive(getState());
return apiFetchNotifications({
exclude_types:
activeFilter === 'all'
? selectSettingsNotificationsExcludedTypes(getState())
: excludeAllTypesExcept(activeFilter),
});
},
async (_params, { getState }) =>
apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
exclude_types: getExcludedTypes(getState()),
}),
({ notifications, accounts, statuses }, { dispatch }) => {
dispatch(importFetchedAccounts(accounts));
dispatch(importFetchedStatuses(statuses));
@ -92,9 +96,12 @@ export const fetchNotifications = createDataLoadingThunk(
export const fetchNotificationsGap = createDataLoadingThunk(
'notificationGroups/fetchGap',
async (params: { gap: NotificationGap }) =>
apiFetchNotifications({ max_id: params.gap.maxId }),
async (params: { gap: NotificationGap }, { getState }) =>
apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
max_id: params.gap.maxId,
exclude_types: getExcludedTypes(getState()),
}),
({ notifications, accounts, statuses }, { dispatch }) => {
dispatch(importFetchedAccounts(accounts));
dispatch(importFetchedStatuses(statuses));
@ -107,8 +114,10 @@ export const fetchNotificationsGap = createDataLoadingThunk(
export const pollRecentNotifications = createDataLoadingThunk(
'notificationGroups/pollRecentNotifications',
async (_params, { getState }) => {
return apiFetchNotifications({
return apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
max_id: undefined,
exclude_types: getExcludedTypes(getState()),
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones
since_id: usePendingItems
? getState().notificationGroups.groups.find(
@ -183,7 +192,6 @@ export const setNotificationsFilter = createAppAsyncThunk(
path: ['notifications', 'quickFilter', 'active'],
value: filterType,
});
// dispatch(expandNotifications({ forceLoad: true }));
void dispatch(fetchNotifications());
dispatch(saveSettings());
},

View File

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

View File

@ -0,0 +1,214 @@
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 } from 'flavours/glitch/store';
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
import { decreasePendingRequestsCount } 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
);
},
},
);
export const acceptNotificationRequest = createDataLoadingThunk(
'notificationRequest/accept',
({ id }: { id: string }) => apiAcceptNotificationRequest(id),
(_data, { dispatch, discardLoadData }) => {
dispatch(decreasePendingRequestsCount(1));
// The payload is not used in any functions
return discardLoadData;
},
);
export const dismissNotificationRequest = createDataLoadingThunk(
'notificationRequest/dismiss',
({ id }: { id: string }) => apiDismissNotificationRequest(id),
(_data, { dispatch, discardLoadData }) => {
dispatch(decreasePendingRequestsCount(1));
// The payload is not used in any functions
return discardLoadData;
},
);
export const acceptNotificationRequests = createDataLoadingThunk(
'notificationRequests/acceptBulk',
({ ids }: { ids: string[] }) => apiAcceptNotificationRequests(ids),
(_data, { dispatch, discardLoadData, actionArg: { ids } }) => {
dispatch(decreasePendingRequestsCount(ids.length));
// The payload is not used in any functions
return discardLoadData;
},
);
export const dismissNotificationRequests = createDataLoadingThunk(
'notificationRequests/dismissBulk',
({ ids }: { ids: string[] }) => apiDismissNotificationRequests(ids),
(_data, { dispatch, discardLoadData, actionArg: { ids } }) => {
dispatch(decreasePendingRequestsCount(ids.length));
// The payload is not used in any functions
return discardLoadData;
},
);

View File

@ -10,7 +10,7 @@ import api, { getLinks } from '../api';
import { unescapeHTML } from '../utils/html';
import { requestNotificationPermission } from '../utils/notifications';
import { fetchFollowRequests, fetchRelationships } from './accounts';
import { fetchFollowRequests } from './accounts';
import {
importFetchedAccount,
importFetchedAccounts,
@ -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,32 +64,10 @@ 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' },
});
const fetchRelatedRelationships = (dispatch, notifications) => {
const accountIds = notifications.filter(item => ['follow', 'follow_request', 'admin.sign_up'].indexOf(item.type) !== -1).map(item => item.account.id);
if (accountIds.length > 0) {
dispatch(fetchRelationships(accountIds));
}
};
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,
});
@ -153,8 +110,6 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered}));
fetchRelatedRelationships(dispatch, [notification]);
} else if (playSound && !filtered) {
dispatch({
type: NOTIFICATIONS_UPDATE_NOOP,
@ -247,7 +202,6 @@ export function expandNotifications({ maxId = undefined, forceLoad = false }) {
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
fetchRelatedRelationships(dispatch, response.data);
dispatch(submitMarkers());
} catch(error) {
dispatch(expandNotificationsFail(error, isLoadingMore));
@ -432,296 +386,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,18 +1,10 @@
import { createAppAsyncThunk } from 'flavours/glitch/store';
import { fetchNotifications } from './notification_groups';
import { expandNotifications } from './notifications';
export const initializeNotifications = createAppAsyncThunk(
'notifications/initialize',
(_, { dispatch, getState }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const enableBeta = getState().settings.getIn(
['notifications', 'groupingBeta'],
false,
) as boolean;
if (enableBeta) 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;
if (alsoFetchContext) {
dispatch(fetchContext(id));
}
if (skipLoading) {
return;

View File

@ -103,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(getState().settings.getIn(['notifications', 'groupingBeta'], false)) {
dispatch(processNewNotificationForGroups(notificationJSON));
}
break;
}
case 'notifications_merged':
const state = getState();
if (state.notifications.top || !state.notifications.mounted)
dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
if(state.settings.getIn(['notifications', 'groupingBeta'], false)) {
dispatch(refreshStaleNotificationGroups());
}
break;
case 'conversation':
// @ts-expect-error
@ -139,22 +135,16 @@ 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(getState().settings.getIn(['notifications', 'groupingBeta'], false)) {
// TODO: polling for merged notifications
try {
await dispatch(pollRecentGroupNotifications());
} catch (error) {
} catch {
// TODO
}
} else {
await dispatch(expandNotifications({}));
}
await dispatch(fetchAnnouncements());
}

View File

@ -42,6 +42,9 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => {
// eslint-disable-next-line import/no-default-export
export default function api(withAuthorization = true) {
return axios.create({
transitional: {
clarifyTimeoutError: true,
},
headers: {
...csrfHeader,
...(withAuthorization ? authorizationTokenFromInitialState() : {}),

View File

@ -1,14 +1,44 @@
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;
grouped_types?: 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 +54,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/requests/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,27 @@
/* Significantly rewritten from upstream to keep the old design for now */
import { FormattedMessage } from 'react-intl';
export const ContentWarning: React.FC<{
text: string;
expanded?: boolean;
onClick?: () => void;
icons?: React.ReactNode[];
}> = ({ text, expanded, onClick, icons }) => (
<p>
<span dangerouslySetInnerHTML={{ __html: text }} className='translate' />{' '}
<button
type='button'
className='status__content__spoiler-link'
onClick={onClick}
aria-expanded={expanded}
>
{expanded ? (
<FormattedMessage id='status.show_less' defaultMessage='Show less' />
) : (
<FormattedMessage id='status.show_more' defaultMessage='Show more' />
)}
{icons}
</button>
</p>
);

View File

@ -0,0 +1,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

@ -0,0 +1,23 @@
import { FormattedMessage } from 'react-intl';
import { StatusBanner, BannerVariant } from './status_banner';
export const FilterWarning: React.FC<{
title: string;
expanded?: boolean;
onClick?: () => void;
}> = ({ title, expanded, onClick }) => (
<StatusBanner
expanded={expanded}
onClick={onClick}
variant={BannerVariant.Blue}
>
<p>
<FormattedMessage
id='filter_warning.matches_filter'
defaultMessage='Matches filter “{title}”'
values={{ title }}
/>
</p>
</StatusBanner>
);

View File

@ -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 })}>
{(!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

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

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

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

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}' },
@ -325,13 +325,16 @@ class StatusActionBar extends ImmutablePureComponent {
}
const filterButton = this.props.onFilter && (
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' iconComponent={VisibilityIcon} onClick={this.handleHideClick} />
</div>
);
const canReact = permissions && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
return (
<div className='status__action-bar'>
<div className='status__action-bar__button-wrapper'>
<IconButton
className='status__action-bar-button'
title={replyTitle}
@ -341,13 +344,20 @@ class StatusActionBar extends ImmutablePureComponent {
counter={showReplyCount ? status.get('replies_count') : undefined}
obfuscateCount
/>
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
<EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} title={intl.formatMessage(messages.react)} icon={AddReactionIcon} disabled={!canReact} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
</div>
{filterButton}
<div className='status__action-bar__button-wrapper'>
<DropdownMenuContainer
scrollKey={scrollKey}
status={status}
@ -358,6 +368,7 @@ class StatusActionBar extends ImmutablePureComponent {
direction='right'
ariaLabel={intl.formatMessage(messages.more)}
/>
</div>
<div className='status__action-bar-spacer' />
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>

View File

@ -0,0 +1,37 @@
import { FormattedMessage } from 'react-intl';
export enum BannerVariant {
Yellow = 'yellow',
Blue = 'blue',
}
export const StatusBanner: React.FC<{
children: React.ReactNode;
variant: BannerVariant;
expanded?: boolean;
onClick?: () => void;
}> = ({ children, variant, expanded, onClick }) => (
<div
className={
variant === BannerVariant.Yellow
? 'content-warning'
: 'content-warning content-warning--filter'
}
>
{children}
<button className='link-button' onClick={onClick}>
{expanded ? (
<FormattedMessage
id='content_warning.hide'
defaultMessage='Hide post'
/>
) : (
<FormattedMessage
id='content_warning.show'
defaultMessage='Show anyway'
/>
)}
</button>
</div>
);

View File

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

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