Merge commit 'f877aa9d70d0d600961989b8e97c0e0ce3ac1db6' into glitch-soc/merge-upstream

Conflicts:
- `.github/dependabot.yml`:
  Upstream made changes, but we had removed it.
  Discarded upstream changes.
- `.rubocop_todo.yml`:
  Upstream regenerated the file, we had some glitch-soc-specific ignores.
- `app/models/account_statuses_filter.rb`:
  Minor upstream code style change where glitch-soc had slightly different code
  due to handling of local-only posts.
  Updated to match upstream's code style.
- `app/models/status.rb`:
  Upstream moved ActiveRecord callback definitions, glitch-soc had an extra one.
  Moved the definitions as upstream did.
- `app/services/backup_service.rb`:
  Upstream rewrote a lot of the backup service, glitch-soc had changes because
  of exporting local-only posts.
  Took upstream changes and added back code to deal with local-only posts.
- `config/routes.rb`:
  Upstream split the file into different files, while glitch-soc had a few
  extra routes.
  Extra routes added to `config/routes/settings.rb`, `config/routes/api.rb`
  and `config/routes/admin.rb`
- `db/schema.rb`:
  Upstream has new migrations, while glitch-soc had an extra migration.
  Updated the expected serial number to match upstream's.
- `lib/mastodon/version.rb`:
  Upstream added support to set version tags from environment variables, while
  glitch-soc has an extra `+glitch` tag.
  Changed the code to support upstream's feature but prepending a `+glitch`.
- `spec/lib/activitypub/activity/create_spec.rb`:
  Minor code style change upstream, while glitch-soc has extra tests due to
  `directMessage` handling.
  Applied upstream's changes while keeping glitch-soc's extra tests.
- `spec/models/concerns/account_interactions_spec.rb`:
  Minor code style change upstream, while glitch-soc has extra tests.
  Applied upstream's changes while keeping glitch-soc's extra tests.
th-downstream
Claire 2 years ago
commit d77fbbed73

@ -43,9 +43,16 @@ jobs:
type=edge,branch=main
type=sha,prefix=,format=long
- name: Generate version suffix
id: version_vars
if: github.repository == 'mastodon/mastodon' && github.event_name == 'push' && github.ref_name == 'main'
run: |
echo mastodon_version_suffix=+edge-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT
- uses: docker/build-push-action@v4
with:
context: .
build-args: MASTODON_VERSION_SUFFIX=${{ steps.version_vars.outputs.mastodon_version_suffix }}
platforms: linux/amd64,linux/arm64
provenance: false
builder: ${{ steps.buildx.outputs.name }}

@ -41,9 +41,15 @@ jobs:
labels: |
org.opencontainers.image.description=Nightly build image used for testing purposes
- name: Generate version suffix
id: version_vars
run: |
echo mastodon_version_suffix=+nightly-$(date +'%Y%m%d') >> $GITHUB_OUTPUT
- uses: docker/build-push-action@v4
with:
context: .
build-args: MASTODON_VERSION_SUFFIX=${{ steps.version_vars.outputs.mastodon_version_suffix }}
platforms: linux/amd64,linux/arm64
provenance: false
builder: ${{ steps.buildx.outputs.name }}

@ -48,7 +48,7 @@ jobs:
run: yarn --frozen-lockfile
- name: ESLint
run: yarn test:lint:js
run: yarn test:lint:js --max-warnings 0
- name: Typecheck
run: yarn test:typecheck

@ -9,7 +9,6 @@ on:
env:
BUNDLE_CLEAN: true
BUNDLE_FROZEN: true
BUNDLE_WITHOUT: 'development production'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -19,8 +18,17 @@ jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
mode:
- production
- test
env:
RAILS_ENV: test
RAILS_ENV: ${{ matrix.mode }}
BUNDLE_WITH: ${{ matrix.mode }}
OTP_SECRET: precompile_placeholder
SECRET_KEY_BASE: precompile_placeholder
steps:
- uses: actions/checkout@v3
@ -50,6 +58,7 @@ jobs:
./bin/rails assets:precompile
- uses: actions/upload-artifact@v3
if: matrix.mode == 'test'
with:
path: |-
./public/assets
@ -97,7 +106,7 @@ jobs:
PAM_ENABLED: true
PAM_DEFAULT_SERVICE: pam_test
PAM_CONTROLLED_SERVICE: pam_test_controlled
BUNDLE_WITH: 'pam_authentication'
BUNDLE_WITH: 'pam_authentication test'
CI_JOBS: ${{ matrix.ci_job }}/4
strategy:

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

@ -65,6 +65,7 @@ Metrics/AbcSize:
Metrics/BlockLength:
CountAsOne: ['array', 'hash', 'heredoc', 'method_call']
Exclude:
- 'config/routes.rb'
- 'lib/mastodon/*_cli.rb'
- 'lib/tasks/*.rake'
- 'app/models/concerns/account_associations.rb'
@ -85,6 +86,7 @@ Metrics/BlockLength:
- 'config/initializers/simple_form.rb'
- 'config/navigation.rb'
- 'config/routes.rb'
- 'config/routes/*.rb'
- 'db/post_migrate/20221101190723_backfill_admin_action_logs.rb'
- 'db/post_migrate/20221206114142_backfill_admin_action_logs_again.rb'
- 'lib/paperclip/gif_transcoder.rb'
@ -130,6 +132,7 @@ Metrics/ClassLength:
- 'app/services/activitypub/process_account_service.rb'
- 'app/services/activitypub/process_status_update_service.rb'
- 'app/services/backup_service.rb'
- 'app/services/bulk_import_service.rb'
- 'app/services/delete_account_service.rb'
- 'app/services/fan_out_on_write_service.rb'
- 'app/services/fetch_link_card_service.rb'
@ -158,6 +161,11 @@ Metrics/MethodLength:
Metrics/ModuleLength:
CountAsOne: [array, heredoc]
# Reason: Prevailing style is argument file paths
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsfilepath
Rails/FilePath:
EnforcedStyle: arguments
# Reason: Prevailing style uses numeric status codes, matches RSpec/Rails/HttpStatus
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railshttpstatus
Rails/HttpStatus:

@ -21,13 +21,6 @@ Layout/ArgumentAlignment:
- 'config/initializers/cors.rb'
- 'config/initializers/session_store.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle.
# SupportedStyles: empty_lines, no_empty_lines
Layout/EmptyLinesAroundBlockBody:
Exclude:
- 'config/routes.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowForAlignment, AllowBeforeTrailingComments, ForceEqualSignAlignment.
Layout/ExtraSpacing:
@ -106,28 +99,6 @@ Lint/AmbiguousOperatorPrecedence:
Exclude:
- 'config/initializers/rack_attack.rb'
# Configuration parameters: AllowedMethods.
# AllowedMethods: enums
Lint/ConstantDefinitionInBlock:
Exclude:
- 'spec/controllers/api/base_controller_spec.rb'
- 'spec/controllers/application_controller_spec.rb'
- 'spec/controllers/concerns/accountable_concern_spec.rb'
- 'spec/controllers/concerns/signature_verification_spec.rb'
- 'spec/lib/activitypub/adapter_spec.rb'
- 'spec/lib/connection_pool/shared_connection_pool_spec.rb'
- 'spec/lib/connection_pool/shared_timed_stack_spec.rb'
- 'spec/models/concerns/remotable_spec.rb'
# Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches.
Lint/DuplicateBranch:
Exclude:
- 'app/lib/permalink_redirector.rb'
- 'app/models/account_statuses_filter.rb'
- 'app/validators/email_mx_validator.rb'
- 'app/validators/vote_validator.rb'
- 'lib/mastodon/maintenance_cli.rb'
# Configuration parameters: AllowComments, AllowEmptyLambdas.
Lint/EmptyBlock:
Exclude:
@ -168,11 +139,6 @@ Lint/EmptyBlock:
- 'spec/models/user_role_spec.rb'
- 'spec/models/web/setting_spec.rb'
# Configuration parameters: AllowComments.
Lint/EmptyClass:
Exclude:
- 'spec/controllers/api/base_controller_spec.rb'
Lint/NonLocalExitFromIterator:
Exclude:
- 'app/helpers/jsonld_helper.rb'
@ -228,6 +194,12 @@ Metrics/AbcSize:
Exclude:
- 'app/serializers/initial_state_serializer.rb'
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode.
# AllowedMethods: refine
Metrics/BlockLength:
Exclude:
- 'app/models/concerns/status_safe_reblog_insert.rb'
# Configuration parameters: CountBlocks, Max.
Metrics/BlockNesting:
Exclude:
@ -305,42 +277,6 @@ Naming/VariableNumber:
- 'spec/models/user_spec.rb'
- 'spec/services/activitypub/fetch_featured_collection_service_spec.rb'
# Configuration parameters: MinSize.
Performance/CollectionLiteralInLoop:
Exclude:
- 'app/models/admin/appeal_filter.rb'
- 'app/models/admin/status_filter.rb'
- 'app/models/relationship_filter.rb'
- 'app/models/trends/preview_card_filter.rb'
- 'app/models/trends/status_filter.rb'
- 'app/presenters/status_relationships_presenter.rb'
- 'app/services/fetch_resource_service.rb'
- 'app/services/suspend_account_service.rb'
- 'app/services/unsuspend_account_service.rb'
- 'config/deploy.rb'
- 'lib/mastodon/media_cli.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
Performance/Count:
Exclude:
- 'app/lib/importer/accounts_index_importer.rb'
- 'app/lib/importer/tags_index_importer.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: SafeMultiline.
Performance/DeletePrefix:
Exclude:
- 'app/controllers/authorize_interactions_controller.rb'
- 'app/controllers/concerns/signature_verification.rb'
- 'app/controllers/intents_controller.rb'
- 'app/lib/activitypub/case_transform.rb'
- 'app/lib/permalink_redirector.rb'
- 'app/lib/webfinger_resource.rb'
- 'app/services/activitypub/fetch_remote_actor_service.rb'
- 'app/services/backup_service.rb'
- 'app/services/resolve_account_service.rb'
- 'app/services/tag_search_service.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
Performance/MapCompact:
Exclude:
@ -360,46 +296,12 @@ Performance/MapCompact:
- 'db/migrate/20200407202420_migrate_unavailable_inboxes.rb'
- 'spec/presenters/status_relationships_presenter_spec.rb'
Performance/MethodObjectAsBlock:
Exclude:
- 'app/models/account_suggestions/source.rb'
- 'spec/models/export_spec.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: AllowRegexpMatch.
Performance/RedundantEqualityComparisonBlock:
Exclude:
- 'spec/requests/link_headers_spec.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: MaxKeyValuePairs.
Performance/RedundantMerge:
Exclude:
- 'config/initializers/paperclip.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: SafeMultiline.
Performance/StartWith:
Exclude:
- 'app/lib/extractor.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: OnlySumOrWithInitialValue.
Performance/Sum:
Exclude:
- 'app/lib/activity_tracker.rb'
- 'app/models/trends/history.rb'
- 'lib/paperclip/color_extractor.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
Performance/TimesMap:
Exclude:
- 'spec/controllers/api/v1/blocks_controller_spec.rb'
- 'spec/controllers/api/v1/mutes_controller_spec.rb'
- 'spec/lib/feed_manager_spec.rb'
- 'spec/lib/request_pool_spec.rb'
- 'spec/models/account_spec.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
Performance/UnfreezeString:
Exclude:
@ -428,116 +330,6 @@ RSpec/AnyInstance:
- 'spec/workers/activitypub/delivery_worker_spec.rb'
- 'spec/workers/web/push_notification_worker_spec.rb'
# Configuration parameters: Prefixes, AllowedPatterns.
# Prefixes: when, with, without
RSpec/ContextWording:
Exclude:
- 'spec/config/initializers/rack_attack_spec.rb'
- 'spec/controllers/accounts_controller_spec.rb'
- 'spec/controllers/activitypub/collections_controller_spec.rb'
- 'spec/controllers/activitypub/inboxes_controller_spec.rb'
- 'spec/controllers/admin/domain_blocks_controller_spec.rb'
- 'spec/controllers/admin/reports/actions_controller_spec.rb'
- 'spec/controllers/admin/statuses_controller_spec.rb'
- 'spec/controllers/api/v1/accounts/relationships_controller_spec.rb'
- 'spec/controllers/api/v1/accounts_controller_spec.rb'
- 'spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb'
- 'spec/controllers/api/v1/emails/confirmations_controller_spec.rb'
- 'spec/controllers/api/v1/instances/activity_controller_spec.rb'
- 'spec/controllers/api/v1/instances/peers_controller_spec.rb'
- 'spec/controllers/api/v1/media_controller_spec.rb'
- 'spec/controllers/api/v2/filters_controller_spec.rb'
- 'spec/controllers/application_controller_spec.rb'
- 'spec/controllers/auth/registrations_controller_spec.rb'
- 'spec/controllers/auth/sessions_controller_spec.rb'
- 'spec/controllers/concerns/cache_concern_spec.rb'
- 'spec/controllers/concerns/challengable_concern_spec.rb'
- 'spec/controllers/concerns/localized_spec.rb'
- 'spec/controllers/concerns/rate_limit_headers_spec.rb'
- 'spec/controllers/instance_actors_controller_spec.rb'
- 'spec/controllers/settings/applications_controller_spec.rb'
- 'spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb'
- 'spec/controllers/statuses_controller_spec.rb'
- 'spec/helpers/admin/account_moderation_notes_helper_spec.rb'
- 'spec/helpers/jsonld_helper_spec.rb'
- 'spec/helpers/routing_helper_spec.rb'
- 'spec/lib/activitypub/activity/accept_spec.rb'
- 'spec/lib/activitypub/activity/announce_spec.rb'
- 'spec/lib/activitypub/activity/create_spec.rb'
- 'spec/lib/activitypub/activity/follow_spec.rb'
- 'spec/lib/activitypub/activity/reject_spec.rb'
- 'spec/lib/advanced_text_formatter_spec.rb'
- 'spec/lib/emoji_formatter_spec.rb'
- 'spec/lib/entity_cache_spec.rb'
- 'spec/lib/feed_manager_spec.rb'
- 'spec/lib/html_aware_formatter_spec.rb'
- 'spec/lib/link_details_extractor_spec.rb'
- 'spec/lib/ostatus/tag_manager_spec.rb'
- 'spec/lib/scope_transformer_spec.rb'
- 'spec/lib/status_cache_hydrator_spec.rb'
- 'spec/lib/status_reach_finder_spec.rb'
- 'spec/lib/text_formatter_spec.rb'
- 'spec/models/account/field_spec.rb'
- 'spec/models/account_spec.rb'
- 'spec/models/admin/account_action_spec.rb'
- 'spec/models/concerns/account_interactions_spec.rb'
- 'spec/models/concerns/remotable_spec.rb'
- 'spec/models/custom_emoji_filter_spec.rb'
- 'spec/models/custom_emoji_spec.rb'
- 'spec/models/email_domain_block_spec.rb'
- 'spec/models/media_attachment_spec.rb'
- 'spec/models/notification_spec.rb'
- 'spec/models/remote_follow_spec.rb'
- 'spec/models/report_spec.rb'
- 'spec/models/session_activation_spec.rb'
- 'spec/models/setting_spec.rb'
- 'spec/models/status_spec.rb'
- 'spec/models/web/push_subscription_spec.rb'
- 'spec/policies/account_moderation_note_policy_spec.rb'
- 'spec/policies/account_policy_spec.rb'
- 'spec/policies/backup_policy_spec.rb'
- 'spec/policies/custom_emoji_policy_spec.rb'
- 'spec/policies/domain_block_policy_spec.rb'
- 'spec/policies/email_domain_block_policy_spec.rb'
- 'spec/policies/instance_policy_spec.rb'
- 'spec/policies/invite_policy_spec.rb'
- 'spec/policies/relay_policy_spec.rb'
- 'spec/policies/report_note_policy_spec.rb'
- 'spec/policies/report_policy_spec.rb'
- 'spec/policies/settings_policy_spec.rb'
- 'spec/policies/tag_policy_spec.rb'
- 'spec/policies/user_policy_spec.rb'
- 'spec/presenters/account_relationships_presenter_spec.rb'
- 'spec/presenters/status_relationships_presenter_spec.rb'
- 'spec/services/account_search_service_spec.rb'
- 'spec/services/account_statuses_cleanup_service_spec.rb'
- 'spec/services/activitypub/fetch_remote_status_service_spec.rb'
- 'spec/services/activitypub/process_account_service_spec.rb'
- 'spec/services/activitypub/process_status_update_service_spec.rb'
- 'spec/services/fetch_link_card_service_spec.rb'
- 'spec/services/fetch_oembed_service_spec.rb'
- 'spec/services/fetch_remote_status_service_spec.rb'
- 'spec/services/follow_service_spec.rb'
- 'spec/services/import_service_spec.rb'
- 'spec/services/notify_service_spec.rb'
- 'spec/services/process_mentions_service_spec.rb'
- 'spec/services/reblog_service_spec.rb'
- 'spec/services/report_service_spec.rb'
- 'spec/services/resolve_account_service_spec.rb'
- 'spec/services/resolve_url_service_spec.rb'
- 'spec/services/search_service_spec.rb'
- 'spec/services/unallow_domain_service_spec.rb'
- 'spec/services/verify_link_service_spec.rb'
- 'spec/validators/disallowed_hashtags_validator_spec.rb'
- 'spec/validators/email_mx_validator_spec.rb'
- 'spec/validators/follow_limit_validator_spec.rb'
- 'spec/validators/poll_validator_spec.rb'
- 'spec/validators/status_pin_validator_spec.rb'
- 'spec/validators/unreserved_username_validator_spec.rb'
- 'spec/validators/url_validator_spec.rb'
- 'spec/workers/move_worker_spec.rb'
- 'spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: SkipBlocks, EnforcedStyle.
# SupportedStyles: described_class, explicit
@ -701,7 +493,6 @@ RSpec/InstanceVariable:
- 'spec/controllers/statuses_cleanup_controller_spec.rb'
- 'spec/models/concerns/account_finder_concern_spec.rb'
- 'spec/models/concerns/account_interactions_spec.rb'
- 'spec/models/concerns/remotable_spec.rb'
- 'spec/models/public_feed_spec.rb'
- 'spec/serializers/activitypub/note_serializer_spec.rb'
- 'spec/serializers/activitypub/update_poll_serializer_spec.rb'
@ -709,17 +500,6 @@ RSpec/InstanceVariable:
- 'spec/services/search_service_spec.rb'
- 'spec/services/unblock_domain_service_spec.rb'
RSpec/LeakyConstantDeclaration:
Exclude:
- 'spec/controllers/api/base_controller_spec.rb'
- 'spec/controllers/application_controller_spec.rb'
- 'spec/controllers/concerns/accountable_concern_spec.rb'
- 'spec/controllers/concerns/signature_verification_spec.rb'
- 'spec/lib/activitypub/adapter_spec.rb'
- 'spec/lib/connection_pool/shared_connection_pool_spec.rb'
- 'spec/lib/connection_pool/shared_timed_stack_spec.rb'
- 'spec/models/concerns/remotable_spec.rb'
RSpec/LetSetup:
Exclude:
- 'spec/controllers/admin/accounts_controller_spec.rb'
@ -745,6 +525,7 @@ RSpec/LetSetup:
- 'spec/controllers/following_accounts_controller_spec.rb'
- 'spec/controllers/oauth/authorized_applications_controller_spec.rb'
- 'spec/controllers/oauth/tokens_controller_spec.rb'
- 'spec/controllers/settings/imports_controller_spec.rb'
- 'spec/lib/activitypub/activity/delete_spec.rb'
- 'spec/lib/vacuum/preview_cards_vacuum_spec.rb'
- 'spec/models/account_spec.rb'
@ -759,6 +540,7 @@ RSpec/LetSetup:
- 'spec/services/activitypub/process_collection_service_spec.rb'
- 'spec/services/batched_remove_status_service_spec.rb'
- 'spec/services/block_domain_service_spec.rb'
- 'spec/services/bulk_import_service_spec.rb'
- 'spec/services/delete_account_service_spec.rb'
- 'spec/services/import_service_spec.rb'
- 'spec/services/notify_service_spec.rb'
@ -831,17 +613,6 @@ RSpec/MultipleExpectations:
RSpec/MultipleMemoizedHelpers:
Max: 21
# This cop supports safe autocorrection (--autocorrect).
RSpec/MultipleSubjects:
Exclude:
- 'spec/controllers/activitypub/collections_controller_spec.rb'
- 'spec/controllers/activitypub/followers_synchronizations_controller_spec.rb'
- 'spec/controllers/activitypub/outboxes_controller_spec.rb'
- 'spec/controllers/api/web/embeds_controller_spec.rb'
- 'spec/controllers/emojis_controller_spec.rb'
- 'spec/controllers/follower_accounts_controller_spec.rb'
- 'spec/controllers/following_accounts_controller_spec.rb'
# Configuration parameters: AllowedGroups.
RSpec/NestedGroups:
Max: 6
@ -867,181 +638,6 @@ RSpec/PredicateMatcher:
- 'spec/models/user_spec.rb'
- 'spec/services/post_status_service_spec.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: Inferences.
RSpec/Rails/InferredSpecType:
Exclude:
- 'spec/controllers/about_controller_spec.rb'
- 'spec/controllers/accounts_controller_spec.rb'
- 'spec/controllers/activitypub/collections_controller_spec.rb'
- 'spec/controllers/activitypub/followers_synchronizations_controller_spec.rb'
- 'spec/controllers/activitypub/inboxes_controller_spec.rb'
- 'spec/controllers/activitypub/outboxes_controller_spec.rb'
- 'spec/controllers/activitypub/replies_controller_spec.rb'
- 'spec/controllers/admin/account_moderation_notes_controller_spec.rb'
- 'spec/controllers/admin/accounts_controller_spec.rb'
- 'spec/controllers/admin/action_logs_controller_spec.rb'
- 'spec/controllers/admin/base_controller_spec.rb'
- 'spec/controllers/admin/change_emails_controller_spec.rb'
- 'spec/controllers/admin/confirmations_controller_spec.rb'
- 'spec/controllers/admin/dashboard_controller_spec.rb'
- 'spec/controllers/admin/disputes/appeals_controller_spec.rb'
- 'spec/controllers/admin/domain_allows_controller_spec.rb'
- 'spec/controllers/admin/domain_blocks_controller_spec.rb'
- 'spec/controllers/admin/email_domain_blocks_controller_spec.rb'
- 'spec/controllers/admin/export_domain_allows_controller_spec.rb'
- 'spec/controllers/admin/export_domain_blocks_controller_spec.rb'
- 'spec/controllers/admin/instances_controller_spec.rb'
- 'spec/controllers/admin/settings/branding_controller_spec.rb'
- 'spec/controllers/admin/tags_controller_spec.rb'
- 'spec/controllers/api/oembed_controller_spec.rb'
- 'spec/controllers/api/v1/accounts/pins_controller_spec.rb'
- 'spec/controllers/api/v1/accounts/search_controller_spec.rb'
- 'spec/controllers/api/v1/accounts_controller_spec.rb'
- 'spec/controllers/api/v1/admin/account_actions_controller_spec.rb'
- 'spec/controllers/api/v1/admin/accounts_controller_spec.rb'
- 'spec/controllers/api/v1/admin/domain_allows_controller_spec.rb'
- 'spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb'
- 'spec/controllers/api/v1/admin/reports_controller_spec.rb'
- 'spec/controllers/api/v1/announcements/reactions_controller_spec.rb'
- 'spec/controllers/api/v1/announcements_controller_spec.rb'
- 'spec/controllers/api/v1/apps_controller_spec.rb'
- 'spec/controllers/api/v1/blocks_controller_spec.rb'
- 'spec/controllers/api/v1/bookmarks_controller_spec.rb'
- 'spec/controllers/api/v1/conversations_controller_spec.rb'
- 'spec/controllers/api/v1/custom_emojis_controller_spec.rb'
- 'spec/controllers/api/v1/domain_blocks_controller_spec.rb'
- 'spec/controllers/api/v1/emails/confirmations_controller_spec.rb'
- 'spec/controllers/api/v1/endorsements_controller_spec.rb'
- 'spec/controllers/api/v1/favourites_controller_spec.rb'
- 'spec/controllers/api/v1/filters_controller_spec.rb'
- 'spec/controllers/api/v1/follow_requests_controller_spec.rb'
- 'spec/controllers/api/v1/followed_tags_controller_spec.rb'
- 'spec/controllers/api/v1/instances/activity_controller_spec.rb'
- 'spec/controllers/api/v1/instances/peers_controller_spec.rb'
- 'spec/controllers/api/v1/instances_controller_spec.rb'
- 'spec/controllers/api/v1/lists_controller_spec.rb'
- 'spec/controllers/api/v1/markers_controller_spec.rb'
- 'spec/controllers/api/v1/media_controller_spec.rb'
- 'spec/controllers/api/v1/mutes_controller_spec.rb'
- 'spec/controllers/api/v1/notifications_controller_spec.rb'
- 'spec/controllers/api/v1/polls/votes_controller_spec.rb'
- 'spec/controllers/api/v1/polls_controller_spec.rb'
- 'spec/controllers/api/v1/reports_controller_spec.rb'
- 'spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb'
- 'spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb'
- 'spec/controllers/api/v1/statuses_controller_spec.rb'
- 'spec/controllers/api/v1/suggestions_controller_spec.rb'
- 'spec/controllers/api/v1/tags_controller_spec.rb'
- 'spec/controllers/api/v1/trends/tags_controller_spec.rb'
- 'spec/controllers/api/v2/admin/accounts_controller_spec.rb'
- 'spec/controllers/api/v2/filters/keywords_controller_spec.rb'
- 'spec/controllers/api/v2/filters/statuses_controller_spec.rb'
- 'spec/controllers/api/v2/filters_controller_spec.rb'
- 'spec/controllers/api/v2/search_controller_spec.rb'
- 'spec/controllers/application_controller_spec.rb'
- 'spec/controllers/auth/challenges_controller_spec.rb'
- 'spec/controllers/auth/confirmations_controller_spec.rb'
- 'spec/controllers/auth/passwords_controller_spec.rb'
- 'spec/controllers/auth/registrations_controller_spec.rb'
- 'spec/controllers/auth/sessions_controller_spec.rb'
- 'spec/controllers/concerns/account_controller_concern_spec.rb'
- 'spec/controllers/concerns/cache_concern_spec.rb'
- 'spec/controllers/concerns/challengable_concern_spec.rb'
- 'spec/controllers/concerns/export_controller_concern_spec.rb'
- 'spec/controllers/concerns/localized_spec.rb'
- 'spec/controllers/concerns/signature_verification_spec.rb'
- 'spec/controllers/concerns/user_tracking_concern_spec.rb'
- 'spec/controllers/disputes/appeals_controller_spec.rb'
- 'spec/controllers/disputes/strikes_controller_spec.rb'
- 'spec/controllers/home_controller_spec.rb'
- 'spec/controllers/instance_actors_controller_spec.rb'
- 'spec/controllers/intents_controller_spec.rb'
- 'spec/controllers/oauth/authorizations_controller_spec.rb'
- 'spec/controllers/oauth/tokens_controller_spec.rb'
- 'spec/controllers/settings/imports_controller_spec.rb'
- 'spec/controllers/settings/profiles_controller_spec.rb'
- 'spec/controllers/statuses_cleanup_controller_spec.rb'
- 'spec/controllers/tags_controller_spec.rb'
- 'spec/controllers/well_known/host_meta_controller_spec.rb'
- 'spec/controllers/well_known/nodeinfo_controller_spec.rb'
- 'spec/controllers/well_known/webfinger_controller_spec.rb'
- 'spec/helpers/accounts_helper_spec.rb'
- 'spec/helpers/admin/account_moderation_notes_helper_spec.rb'
- 'spec/helpers/admin/action_logs_helper_spec.rb'
- 'spec/helpers/flashes_helper_spec.rb'
- 'spec/helpers/formatting_helper_spec.rb'
- 'spec/helpers/home_helper_spec.rb'
- 'spec/helpers/routing_helper_spec.rb'
- 'spec/mailers/admin_mailer_spec.rb'
- 'spec/mailers/notification_mailer_spec.rb'
- 'spec/mailers/user_mailer_spec.rb'
- 'spec/models/account/field_spec.rb'
- 'spec/models/account_alias_spec.rb'
- 'spec/models/account_conversation_spec.rb'
- 'spec/models/account_deletion_request_spec.rb'
- 'spec/models/account_domain_block_spec.rb'
- 'spec/models/account_migration_spec.rb'
- 'spec/models/account_moderation_note_spec.rb'
- 'spec/models/account_spec.rb'
- 'spec/models/account_statuses_cleanup_policy_spec.rb'
- 'spec/models/admin/account_action_spec.rb'
- 'spec/models/admin/action_log_spec.rb'
- 'spec/models/announcement_mute_spec.rb'
- 'spec/models/announcement_reaction_spec.rb'
- 'spec/models/announcement_spec.rb'
- 'spec/models/backup_spec.rb'
- 'spec/models/block_spec.rb'
- 'spec/models/canonical_email_block_spec.rb'
- 'spec/models/conversation_mute_spec.rb'
- 'spec/models/conversation_spec.rb'
- 'spec/models/custom_emoji_spec.rb'
- 'spec/models/custom_filter_keyword_spec.rb'
- 'spec/models/custom_filter_spec.rb'
- 'spec/models/device_spec.rb'
- 'spec/models/domain_block_spec.rb'
- 'spec/models/email_domain_block_spec.rb'
- 'spec/models/encrypted_message_spec.rb'
- 'spec/models/favourite_spec.rb'
- 'spec/models/featured_tag_spec.rb'
- 'spec/models/follow_recommendation_suppression_spec.rb'
- 'spec/models/follow_request_spec.rb'
- 'spec/models/follow_spec.rb'
- 'spec/models/home_feed_spec.rb'
- 'spec/models/identity_spec.rb'
- 'spec/models/import_spec.rb'
- 'spec/models/invite_spec.rb'
- 'spec/models/list_account_spec.rb'
- 'spec/models/list_spec.rb'
- 'spec/models/login_activity_spec.rb'
- 'spec/models/media_attachment_spec.rb'
- 'spec/models/mention_spec.rb'
- 'spec/models/mute_spec.rb'
- 'spec/models/notification_spec.rb'
- 'spec/models/poll_vote_spec.rb'
- 'spec/models/preview_card_spec.rb'
- 'spec/models/preview_card_trend_spec.rb'
- 'spec/models/public_feed_spec.rb'
- 'spec/models/relay_spec.rb'
- 'spec/models/scheduled_status_spec.rb'
- 'spec/models/session_activation_spec.rb'
- 'spec/models/setting_spec.rb'
- 'spec/models/site_upload_spec.rb'
- 'spec/models/status_pin_spec.rb'
- 'spec/models/status_spec.rb'
- 'spec/models/status_stat_spec.rb'
- 'spec/models/status_trend_spec.rb'
- 'spec/models/system_key_spec.rb'
- 'spec/models/tag_follow_spec.rb'
- 'spec/models/unavailable_domain_spec.rb'
- 'spec/models/user_invite_request_spec.rb'
- 'spec/models/user_role_spec.rb'
- 'spec/models/user_spec.rb'
- 'spec/models/web/push_subscription_spec.rb'
- 'spec/models/web/setting_spec.rb'
- 'spec/models/webauthn_credentials_spec.rb'
- 'spec/models/webhook_spec.rb'
RSpec/RepeatedExample:
Exclude:
- 'spec/policies/status_policy_spec.rb'
@ -1120,7 +716,6 @@ RSpec/VerifiedDoubles:
- 'spec/controllers/api/web/embeds_controller_spec.rb'
- 'spec/controllers/auth/sessions_controller_spec.rb'
- 'spec/controllers/disputes/appeals_controller_spec.rb'
- 'spec/controllers/settings/imports_controller_spec.rb'
- 'spec/helpers/statuses_helper_spec.rb'
- 'spec/lib/suspicious_sign_in_detector_spec.rb'
- 'spec/models/account/field_spec.rb'
@ -1148,19 +743,6 @@ RSpec/VerifiedDoubles:
- 'spec/workers/feed_insert_worker_spec.rb'
- 'spec/workers/regeneration_worker_spec.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: Include.
# Include: app/models/**/*.rb
Rails/ActiveRecordCallbacksOrder:
Exclude:
- 'app/models/account.rb'
- 'app/models/account_conversation.rb'
- 'app/models/announcement_reaction.rb'
- 'app/models/block.rb'
- 'app/models/media_attachment.rb'
- 'app/models/session_activation.rb'
- 'app/models/status.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
Rails/ApplicationController:
Exclude:
@ -1216,12 +798,6 @@ Rails/CreateTableWithTimestamps:
- 'db/migrate/20220824233535_create_status_trends.rb'
- 'db/migrate/20221006061337_create_preview_card_trends.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: Severity.
Rails/DeprecatedActiveModelErrorsMethods:
Exclude:
- 'lib/mastodon/accounts_cli.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: Severity.
Rails/DuplicateAssociation:
@ -1235,74 +811,6 @@ Rails/Exit:
Exclude:
- 'config/boot.rb'
# Configuration parameters: EnforcedStyle.
# SupportedStyles: slashes, arguments
Rails/FilePath:
Exclude:
- 'app/lib/themes.rb'
- 'app/models/setting.rb'
- 'app/validators/reaction_validator.rb'
- 'config/environments/test.rb'
- 'config/initializers/locale.rb'
- 'db/migrate/20170716191202_add_hide_notifications_to_mute.rb'
- 'db/migrate/20171005171936_add_disabled_to_custom_emojis.rb'
- 'db/migrate/20171028221157_add_reblogs_to_follows.rb'
- 'db/migrate/20171107143332_add_memorial_to_accounts.rb'
- 'db/migrate/20171107143624_add_disabled_to_users.rb'
- 'db/migrate/20171109012327_add_moderator_to_accounts.rb'
- 'db/migrate/20171130000000_add_embed_url_to_preview_cards.rb'
- 'db/migrate/20180615122121_add_autofollow_to_invites.rb'
- 'db/migrate/20180707154237_add_whole_word_to_custom_filter.rb'
- 'db/migrate/20180814171349_add_confidential_to_doorkeeper_application.rb'
- 'db/migrate/20181010141500_add_silent_to_mentions.rb'
- 'db/migrate/20181017170937_add_reject_reports_to_domain_blocks.rb'
- 'db/migrate/20181018205649_add_unread_to_account_conversations.rb'
- 'db/migrate/20181127130500_identity_id_to_bigint.rb'
- 'db/migrate/20181127165847_add_show_replies_to_lists.rb'
- 'db/migrate/20190201012802_add_overwrite_to_imports.rb'
- 'db/migrate/20190306145741_add_lock_version_to_polls.rb'
- 'db/migrate/20190307234537_add_approved_to_users.rb'
- 'db/migrate/20191001213028_add_lock_version_to_account_stats.rb'
- 'db/migrate/20191212003415_increase_backup_size.rb'
- 'db/migrate/20200312144258_add_title_to_account_warning_presets.rb'
- 'db/migrate/20200620164023_add_fixed_lowercase_index_to_accounts.rb'
- 'db/migrate/20200917192924_add_notify_to_follows.rb'
- 'db/migrate/20201218054746_add_obfuscate_to_domain_blocks.rb'
- 'db/migrate/20210421121431_add_case_insensitive_btree_index_to_tags.rb'
- 'db/migrate/20211231080958_add_category_to_reports.rb'
- 'db/migrate/20220613110834_add_action_to_custom_filters.rb'
- 'db/post_migrate/20220307083603_optimize_null_index_conversations_uri.rb'
- 'db/post_migrate/20220310060545_optimize_null_index_statuses_in_reply_to_account_id.rb'
- 'db/post_migrate/20220310060556_optimize_null_index_statuses_in_reply_to_id.rb'
- 'db/post_migrate/20220310060614_optimize_null_index_media_attachments_scheduled_status_id.rb'
- 'db/post_migrate/20220310060626_optimize_null_index_media_attachments_shortcode.rb'
- 'db/post_migrate/20220310060641_optimize_null_index_users_reset_password_token.rb'
- 'db/post_migrate/20220310060653_optimize_null_index_users_created_by_application_id.rb'
- 'db/post_migrate/20220310060706_optimize_null_index_statuses_uri.rb'
- 'db/post_migrate/20220310060722_optimize_null_index_accounts_moved_to_account_id.rb'
- 'db/post_migrate/20220310060740_optimize_null_index_oauth_access_tokens_refresh_token.rb'
- 'db/post_migrate/20220310060750_optimize_null_index_accounts_url.rb'
- 'db/post_migrate/20220310060809_optimize_null_index_oauth_access_tokens_resource_owner_id.rb'
- 'db/post_migrate/20220310060833_optimize_null_index_announcement_reactions_custom_emoji_id.rb'
- 'db/post_migrate/20220310060854_optimize_null_index_appeals_approved_by_account_id.rb'
- 'db/post_migrate/20220310060913_optimize_null_index_account_migrations_target_account_id.rb'
- 'db/post_migrate/20220310060926_optimize_null_index_appeals_rejected_by_account_id.rb'
- 'db/post_migrate/20220310060939_optimize_null_index_list_accounts_follow_id.rb'
- 'db/post_migrate/20220310060959_optimize_null_index_web_push_subscriptions_access_token_id.rb'
- 'db/post_migrate/20220613110802_remove_whole_word_from_custom_filters.rb'
- 'db/post_migrate/20220613110903_remove_irreversible_from_custom_filters.rb'
- 'db/post_migrate/20220617202502_migrate_roles.rb'
- 'db/seeds.rb'
- 'db/seeds/03_roles.rb'
- 'lib/tasks/branding.rake'
- 'lib/tasks/emojis.rake'
- 'lib/tasks/repo.rake'
- 'spec/controllers/admin/custom_emojis_controller_spec.rb'
- 'spec/fabricators/custom_emoji_fabricator.rb'
- 'spec/fabricators/site_upload_fabricator.rb'
- 'spec/rails_helper.rb'
- 'spec/spec_helper.rb'
# Configuration parameters: Include.
# Include: app/models/**/*.rb
Rails/HasAndBelongsToMany:
@ -1445,12 +953,29 @@ Rails/SkipsModelValidations:
- 'spec/services/follow_service_spec.rb'
- 'spec/services/update_account_service_spec.rb'
Rails/TransactionExitStatement:
# Configuration parameters: Include.
# Include: db/**/*.rb
Rails/ThreeStateBooleanColumn:
Exclude:
- 'app/lib/activitypub/activity/announce.rb'
- 'app/lib/activitypub/activity/create.rb'
- 'app/lib/activitypub/activity/delete.rb'
- 'app/services/activitypub/process_account_service.rb'
- 'db/migrate/20160325130944_add_admin_to_users.rb'
- 'db/migrate/20161123093447_add_sensitive_to_statuses.rb'
- 'db/migrate/20170123203248_add_reject_media_to_domain_blocks.rb'
- 'db/migrate/20170127165745_add_devise_two_factor_to_users.rb'
- 'db/migrate/20170209184350_add_reply_to_statuses.rb'
- 'db/migrate/20170330163835_create_imports.rb'
- 'db/migrate/20170905165803_add_local_to_statuses.rb'
- 'db/migrate/20181203021853_add_discoverable_to_accounts.rb'
- 'db/migrate/20190509164208_add_by_moderator_to_tombstone.rb'
- 'db/migrate/20190805123746_add_capabilities_to_tags.rb'
- 'db/migrate/20191212163405_add_hide_collections_to_accounts.rb'
- 'db/migrate/20200309150742_add_forwarded_to_reports.rb'
- 'db/migrate/20210609202149_create_login_activities.rb'
- 'db/migrate/20210621221010_add_skip_sign_in_token_to_users.rb'
- 'db/migrate/20211031031021_create_preview_card_providers.rb'
- 'db/migrate/20211115032527_add_trendable_to_preview_cards.rb'
- 'db/migrate/20220202200743_add_trendable_to_accounts.rb'
- 'db/migrate/20220202200926_add_trendable_to_statuses.rb'
- 'db/migrate/20220303000827_add_ordered_media_attachment_ids_to_status_edits.rb'
# Configuration parameters: Include.
# Include: app/models/**/*.rb
@ -1519,12 +1044,6 @@ Style/CaseEquality:
Exclude:
- 'config/initializers/trusted_proxies.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: MinBranchesCount.
Style/CaseLikeIf:
Exclude:
- 'app/controllers/concerns/signature_verification.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowedMethods, AllowedPatterns.
# AllowedMethods: ==, equal?, eql?
@ -1542,16 +1061,10 @@ Style/CombinableLoops:
- 'app/models/form/custom_emoji_batch.rb'
- 'app/models/form/ip_block_batch.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
Style/ConcatArrayLiterals:
Exclude:
- 'app/lib/feed_manager.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowedVars.
Style/FetchEnvVar:
Exclude:
- 'app/helpers/application_helper.rb'
- 'app/lib/redis_configuration.rb'
- 'app/lib/translation_service.rb'
- 'config/environments/development.rb'
@ -2001,7 +1514,6 @@ Style/GuardClause:
- 'app/controllers/auth/passwords_controller.rb'
- 'app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb'
- 'app/lib/activitypub/activity/block.rb'
- 'app/lib/connection_pool/shared_connection_pool.rb'
- 'app/lib/request.rb'
- 'app/lib/request_pool.rb'
- 'app/lib/webfinger.rb'
@ -2036,7 +1548,6 @@ Style/HashAsLastArrayItem:
Exclude:
- 'app/controllers/admin/statuses_controller.rb'
- 'app/controllers/api/v1/statuses_controller.rb'
- 'app/models/account.rb'
- 'app/models/concerns/account_counters.rb'
- 'app/models/concerns/status_threading_concern.rb'
- 'app/models/status.rb'
@ -2044,19 +1555,6 @@ Style/HashAsLastArrayItem:
- 'app/services/notify_service.rb'
- 'db/migrate/20181024224956_migrate_account_conversations.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle, EnforcedShorthandSyntax, UseHashRocketsWithSymbolValues, PreferHashRocketsForNonAlnumEndingSymbols.
# SupportedStyles: ruby19, hash_rockets, no_mixed_keys, ruby19_no_mixed_keys
# SupportedShorthandSyntax: always, never, either, consistent
Style/HashSyntax:
Exclude:
- 'app/helpers/application_helper.rb'
- 'app/models/media_attachment.rb'
- 'lib/terrapin/multi_pipe_extensions.rb'
- 'spec/controllers/admin/reports/actions_controller_spec.rb'
- 'spec/controllers/admin/statuses_controller_spec.rb'
- 'spec/controllers/concerns/signature_verification_spec.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
Style/HashTransformValues:
Exclude:
@ -2074,22 +1572,8 @@ Style/IfUnlessModifier:
# Configuration parameters: InverseMethods, InverseBlocks.
Style/InverseMethods:
Exclude:
- 'app/controllers/concerns/signature_verification.rb'
- 'app/helpers/jsonld_helper.rb'
- 'app/lib/activitypub/activity/create.rb'
- 'app/lib/activitypub/activity/move.rb'
- 'app/lib/feed_manager.rb'
- 'app/lib/link_details_extractor.rb'
- 'app/models/concerns/attachmentable.rb'
- 'app/models/concerns/remotable.rb'
- 'app/models/custom_filter.rb'
- 'app/models/webhook.rb'
- 'app/services/activitypub/process_status_update_service.rb'
- 'app/services/fetch_link_card_service.rb'
- 'app/services/search_service.rb'
- 'app/services/update_account_service.rb'
- 'app/workers/web/push_notification_worker.rb'
- 'lib/paperclip/color_extractor.rb'
- 'spec/controllers/activitypub/replies_controller_spec.rb'
# This cop supports safe autocorrection (--autocorrect).
@ -2110,12 +1594,10 @@ Style/MapToHash:
# SupportedStyles: literals, strict
Style/MutableConstant:
Exclude:
- 'app/models/account.rb'
- 'app/models/tag.rb'
- 'app/services/delete_account_service.rb'
- 'config/initializers/twitter_regex.rb'
- 'lib/mastodon/migration_warning.rb'
- 'spec/controllers/api/base_controller_spec.rb'
# This cop supports safe autocorrection (--autocorrect).
Style/NilLambda:
@ -2199,7 +1681,6 @@ Style/RedundantRegexpEscape:
Style/RegexpLiteral:
Exclude:
- 'app/lib/link_details_extractor.rb'
- 'app/lib/permalink_redirector.rb'
- 'app/lib/plain_text_formatter.rb'
- 'app/lib/tag_manager.rb'
- 'app/lib/text_formatter.rb'
@ -2321,11 +1802,14 @@ Style/TrailingCommaInHashLiteral:
- 'config/environments/test.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: WordRegex.
# Configuration parameters: EnforcedStyle, MinSize, WordRegex.
# SupportedStyles: percent, brackets
Style/WordArray:
EnforcedStyle: percent
MinSize: 6
Exclude:
- 'app/helpers/languages_helper.rb'
- 'config/initializers/cors.rb'
- 'spec/controllers/settings/imports_controller_spec.rb'
- 'spec/models/form/import_spec.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns.

@ -1,4 +1,5 @@
ffmpeg
libopenblas0-pthread
libpq-dev
libxdamage1
libxfixes3

@ -41,6 +41,10 @@ RUN apt-get update && \
FROM node:${NODE_VERSION}
# Use those args to specify your own version flags & suffixes
ARG MASTODON_VERSION_FLAGS=""
ARG MASTODON_VERSION_SUFFIX=""
ARG UID="991"
ARG GID="991"
@ -84,7 +88,9 @@ COPY --chown=mastodon:mastodon --from=build /opt/mastodon /opt/mastodon
ENV RAILS_ENV="production" \
NODE_ENV="production" \
RAILS_SERVE_STATIC_FILES="true" \
BIND="0.0.0.0"
BIND="0.0.0.0" \
MASTODON_VERSION_FLAGS="${MASTODON_VERSION_FLAGS}" \
MASTODON_VERSION_SUFFIX="${MASTODON_VERSION_SUFFIX}"
# Set the run user
USER mastodon

@ -30,10 +30,7 @@ gem 'browser'
gem 'charlock_holmes', '~> 0.7.7'
gem 'chewy', '~> 7.3'
gem 'devise', '~> 4.9'
# The below `v4.x` branch allows attr_encrypted 4.x, which is required for Rails 7.
# Once a new gem version is pushed, we can go back to released gem and off of github branch.
gem 'devise-two-factor', github: 'tinfoil/devise-two-factor', branch: 'v4.x'
gem 'attr_encrypted', '~> 4.0'
gem 'devise-two-factor', '~> 4.1'
group :pam_authentication, optional: true do
gem 'devise_pam_authenticatable2', '~> 9.2'
@ -164,3 +161,4 @@ gem 'hcaptcha', '~> 7.1'
gem 'cocoon', '~> 1.2'
gem 'net-http', '~> 0.3.2'
gem 'rubyzip', '~> 2.3'

@ -27,18 +27,6 @@ GIT
rails-settings-cached (0.6.6)
rails (>= 4.2.0)
GIT
remote: https://github.com/tinfoil/devise-two-factor.git
revision: e685f91ce62d036259885fbe31fcb4fa930bcfcb
branch: v4.x
specs:
devise-two-factor (4.0.2)
activesupport (< 7.1)
attr_encrypted (>= 1.3, < 5, != 2)
devise (~> 4.0)
railties (< 7.1)
rotp (~> 6.0)
GEM
remote: https://rubygems.org/
specs:
@ -218,6 +206,12 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-two-factor (4.1.0)
activesupport (< 7.1)
attr_encrypted (>= 1.3, < 5, != 2)
devise (~> 4.0)
railties (< 7.1)
rotp (~> 6.0)
devise_pam_authenticatable2 (9.2.0)
devise (>= 4.0.0)
rpam2 (~> 4.0)
@ -354,15 +348,15 @@ GEM
ipaddress (0.8.3)
jmespath (1.6.2)
json (2.6.3)
json-canonicalization (0.3.1)
json-canonicalization (0.3.2)
json-jwt (1.15.3)
activesupport (>= 4.2)
aes_key_wrap
bindata
httpclient
json-ld (3.2.4)
json-ld (3.2.5)
htmlentities (~> 4.3)
json-canonicalization (~> 0.3)
json-canonicalization (~> 0.3, >= 0.3.2)
link_header (~> 0.0, >= 0.0.8)
multi_json (~> 1.15)
rack (>= 2.2, < 4)
@ -492,7 +486,7 @@ GEM
parslet (2.0.0)
pastel (0.8.0)
tty-color (~> 0.5)
pg (1.5.2)
pg (1.5.3)
pghero (3.3.3)
activerecord (>= 6)
pkg-config (1.5.1)
@ -626,7 +620,7 @@ GEM
rubocop-performance (1.17.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.18.0)
rubocop-rails (2.19.1)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
@ -638,6 +632,7 @@ GEM
nokogiri (>= 1.10.5)
rexml
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
rufus-scheduler (3.8.2)
fugit (~> 1.1, >= 1.1.6)
safety_net_attestation (0.4.0)
@ -777,7 +772,6 @@ DEPENDENCIES
active_model_serializers (~> 0.10)
addressable (~> 2.8)
annotate (~> 3.2)
attr_encrypted (~> 4.0)
aws-sdk-s3 (~> 1.120)
better_errors (~> 2.9)
binding_of_caller (~> 1.0)
@ -799,7 +793,7 @@ DEPENDENCIES
concurrent-ruby
connection_pool
devise (~> 4.9)
devise-two-factor!
devise-two-factor (~> 4.1)
devise_pam_authenticatable2 (~> 9.2)
discard (~> 1.2)
doorkeeper (~> 5.6)
@ -879,6 +873,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-progressbar (~> 1.13)
rubyzip (~> 2.3)
sanitize (~> 6.0)
scenic (~> 1.7)
sidekiq (~> 6.5)

@ -33,7 +33,7 @@ module Admin
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
@domain_block.save
flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe
@domain_block.errors.delete(:domain)
render :new
else

@ -15,7 +15,8 @@ class Api::V1::MediaController < Api::BaseController
render json: @media_attachment, serializer: REST::MediaAttachmentSerializer
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
render json: file_type_error, status: 422
rescue Paperclip::Error
rescue Paperclip::Error => e
Rails.logger.error "#{e.class}: #{e.message}"
render json: processing_error, status: 500
end

@ -6,7 +6,8 @@ class Api::V2::MediaController < Api::V1::MediaController
render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: @media_attachment.not_processed? ? 202 : 200
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
render json: file_type_error, status: 422
rescue Paperclip::Error
rescue Paperclip::Error => e
Rails.logger.error "#{e.class}: #{e.message}"
render json: processing_error, status: 500
end
end

@ -60,7 +60,7 @@ class AuthorizeInteractionsController < ApplicationController
end
def uri_param
params[:uri] || params.fetch(:acct, '').gsub(/\Aacct:/, '')
params[:uri] || params.fetch(:acct, '').delete_prefix('acct:')
end
def set_body_classes

@ -180,14 +180,15 @@ module SignatureVerification
def build_signed_string
signed_headers.map do |signed_header|
if signed_header == Request::REQUEST_TARGET
case signed_header
when Request::REQUEST_TARGET
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
elsif signed_header == '(created)'
when '(created)'
raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
"(created): #{signature_params['created']}"
elsif signed_header == '(expires)'
when '(expires)'
raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
@ -244,7 +245,7 @@ module SignatureVerification
end
if key_id.start_with?('acct:')
stoplight_wrap_request { ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, ''), suppress_errors: false) }
stoplight_wrap_request { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) }
elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
account = ActivityPub::TagManager.instance.uri_to_actor(key_id)
account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false, suppress_errors: false) }

@ -9,7 +9,7 @@ class IntentsController < ApplicationController
if uri.scheme == 'web+mastodon'
case uri.host
when 'follow'
return redirect_to authorize_interaction_path(uri: uri.query_values['uri'].gsub(/\Aacct:/, ''))
return redirect_to authorize_interaction_path(uri: uri.query_values['uri'].delete_prefix('acct:'))
when 'share'
return redirect_to share_path(text: uri.query_values['text'])
end

@ -16,7 +16,7 @@ class MediaProxyController < ApplicationController
rescue_from HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, with: :internal_server_error
def show
with_lock("media_download:#{params[:id]}") do
with_redis_lock("media_download:#{params[:id]}") do
@media_attachment = MediaAttachment.remote.attached.find(params[:id])
authorize @media_attachment.status, :show?
redownload! if @media_attachment.needs_redownload? && !reject_media?

@ -15,7 +15,7 @@ class Settings::ExportsController < Settings::BaseController
def create
backup = nil
with_lock("backup:#{current_user.id}") do
with_redis_lock("backup:#{current_user.id}") do
authorize :backup, :create?
backup = current_user.backups.create!
end

@ -1,31 +1,97 @@
# frozen_string_literal: true
require 'csv'
class Settings::ImportsController < Settings::BaseController
before_action :set_account
before_action :set_bulk_import, only: [:show, :confirm, :destroy]
before_action :set_recent_imports, only: [:index]
TYPE_TO_FILENAME_MAP = {
following: 'following_accounts_failures.csv',
blocking: 'blocked_accounts_failures.csv',
muting: 'muted_accounts_failures.csv',
domain_blocking: 'blocked_domains_failures.csv',
bookmarks: 'bookmarks_failures.csv',
}.freeze
TYPE_TO_HEADERS_MAP = {
following: ['Account address', 'Show boosts', 'Notify on new posts', 'Languages'],
blocking: false,
muting: ['Account address', 'Hide notifications'],
domain_blocking: false,
bookmarks: false,
}.freeze
def index
@import = Form::Import.new(current_account: current_account)
end
def show; end
def failures
@bulk_import = current_account.bulk_imports.where(state: :finished).find(params[:id])
respond_to do |format|
format.csv do
filename = TYPE_TO_FILENAME_MAP[@bulk_import.type.to_sym]
headers = TYPE_TO_HEADERS_MAP[@bulk_import.type.to_sym]
export_data = CSV.generate(headers: headers, write_headers: true) do |csv|
@bulk_import.rows.find_each do |row|
case @bulk_import.type.to_sym
when :following
csv << [row.data['acct'], row.data.fetch('show_reblogs', true), row.data.fetch('notify', false), row.data['languages']&.join(', ')]
when :blocking
csv << [row.data['acct']]
when :muting
csv << [row.data['acct'], row.data.fetch('hide_notifications', true)]
when :domain_blocking
csv << [row.data['domain']]
when :bookmarks
csv << [row.data['uri']]
end
end
end
def show
@import = Import.new
send_data export_data, filename: filename
end
end
end
def confirm
@bulk_import.update!(state: :scheduled)
BulkImportWorker.perform_async(@bulk_import.id)
redirect_to settings_imports_path, notice: I18n.t('imports.success')
end
def create
@import = Import.new(import_params)
@import.account = @account
@import = Form::Import.new(import_params.merge(current_account: current_account))
if @import.save
ImportWorker.perform_async(@import.id)
redirect_to settings_import_path, notice: I18n.t('imports.success')
redirect_to settings_import_path(@import.bulk_import.id)
else
render :show
# We need to set recent imports as we are displaying the index again
set_recent_imports
render :index
end
end
def destroy
@bulk_import.destroy!
redirect_to settings_imports_path
end
private
def set_account
@account = current_user.account
def import_params
params.require(:form_import).permit(:data, :type, :mode)
end
def import_params
params.require(:import).permit(:data, :type, :mode)
def set_bulk_import
@bulk_import = current_account.bulk_imports.where(state: :unconfirmed).find(params[:id])
end
def set_recent_imports
@recent_imports = current_account.bulk_imports.reorder(id: :desc).limit(10)
end
end

@ -1,6 +1,6 @@
# frozen_string_literal: true
class Settings::Preferences::AppearanceController < Settings::PreferencesController
class Settings::Preferences::AppearanceController < Settings::Preferences::BaseController
private
def after_update_redirect_path

@ -1,6 +1,6 @@
# frozen_string_literal: true
class Settings::PreferencesController < Settings::BaseController
class Settings::Preferences::BaseController < Settings::BaseController
def show; end
def update
@ -15,7 +15,7 @@ class Settings::PreferencesController < Settings::BaseController
private
def after_update_redirect_path
settings_preferences_path
raise 'Override in controller'
end
def user_params

@ -1,6 +1,6 @@
# frozen_string_literal: true
class Settings::Preferences::NotificationsController < Settings::PreferencesController
class Settings::Preferences::NotificationsController < Settings::Preferences::BaseController
private
def after_update_redirect_path

@ -1,6 +1,6 @@
# frozen_string_literal: true
class Settings::Preferences::OtherController < Settings::PreferencesController
class Settings::Preferences::OtherController < Settings::Preferences::BaseController
private
def after_update_redirect_path

@ -18,7 +18,14 @@ module WellKnown
private
def set_account
@account = Account.find_local!(username_from_resource)
username = username_from_resource
@account = begin
if username == Rails.configuration.x.local_domain
Account.representative
else
Account.find_local!(username)
end
end
end
def username_from_resource

@ -32,10 +32,6 @@ module ApplicationHelper
paths.any? { |path| current_page?(path) } ? 'active' : ''
end
def active_link_to(label, path, **options)
link_to label, path, options.merge(class: active_nav_class(path))
end
def show_landing_strip?
!user_signed_in? && !single_user_mode?
end
@ -147,7 +143,7 @@ module ApplicationHelper
if prefers_autoplay?
image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:")
else
image_tag(custom_emoji.image.url(:static), class: 'emojione custom-emoji', alt: ":#{custom_emoji.shortcode}", 'data-original' => full_asset_url(custom_emoji.image.url), 'data-static' => full_asset_url(custom_emoji.image.url(:static)))
image_tag(custom_emoji.image.url(:static), :class => 'emojione custom-emoji', :alt => ":#{custom_emoji.shortcode}", 'data-original' => full_asset_url(custom_emoji.image.url), 'data-static' => full_asset_url(custom_emoji.image.url(:static)))
end
end
@ -174,11 +170,11 @@ module ApplicationHelper
end
def storage_host
"https://#{ENV['S3_ALIAS_HOST'].presence || ENV['S3_CLOUDFRONT_HOST']}"
URI::HTTPS.build(host: storage_host_name).to_s
end
def storage_host?
ENV['S3_ALIAS_HOST'].present? || ENV['S3_CLOUDFRONT_HOST'].present?
storage_host_name.present?
end
def quote_wrap(text, line_width: 80, break_sequence: "\n")
@ -236,4 +232,10 @@ module ApplicationHelper
def prerender_custom_emojis(html, custom_emojis, other_options = {})
EmojiFormatter.new(html, custom_emojis, other_options.merge(animate: prefers_autoplay?)).to_s
end
private
def storage_host_name
ENV.fetch('S3_ALIAS_HOST', nil) || ENV.fetch('S3_CLOUDFRONT_HOST', nil)
end
end

@ -1,17 +0,0 @@
export const APP_FOCUS = 'APP_FOCUS';
export const APP_UNFOCUS = 'APP_UNFOCUS';
export const focusApp = () => ({
type: APP_FOCUS,
});
export const unfocusApp = () => ({
type: APP_UNFOCUS,
});
export const APP_LAYOUT_CHANGE = 'APP_LAYOUT_CHANGE';
export const changeLayout = layout => ({
type: APP_LAYOUT_CHANGE,
layout,
});

@ -0,0 +1,10 @@
import { createAction } from '@reduxjs/toolkit';
export const focusApp = createAction('APP_FOCUS');
export const unfocusApp = createAction('APP_UNFOCUS');
type ChangeLayoutPayload = {
layout: 'mobile' | 'single-column' | 'multi-column';
};
export const changeLayout =
createAction<ChangeLayoutPayload>('APP_LAYOUT_CHANGE');

@ -84,7 +84,7 @@ const DIGIT_CHARACTERS = [
'~',
];
export const decode83 = (str) => {
export const decode83 = (str: string) => {
let value = 0;
let c, digit;
@ -97,13 +97,13 @@ export const decode83 = (str) => {
return value;
};
export const intToRGB = int => ({
export const intToRGB = (int: number) => ({
r: Math.max(0, (int >> 16)),
g: Math.max(0, (int >> 8) & 255),
b: Math.max(0, (int & 255)),
});
export const getAverageFromBlurhash = blurhash => {
export const getAverageFromBlurhash = (blurhash: string) => {
if (!blurhash) {
return null;
}

@ -1,4 +1,4 @@
export default function compareId (id1, id2) {
export default function compareId (id1: string, id2: string) {
if (id1 === id2) {
return 0;
}

@ -1,65 +0,0 @@
// @ts-check
import { decode } from 'blurhash';
import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
/**
* @typedef BlurhashPropsBase
* @property {string?} hash Hash to render
* @property {number} width
* Width of the blurred region in pixels. Defaults to 32
* @property {number} [height]
* Height of the blurred region in pixels. Defaults to width
* @property {boolean} [dummy]
* Whether dummy mode is enabled. If enabled, nothing is rendered
* and canvas left untouched
*/
/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */
/**
* Component that is used to render blurred of blurhash string
* @param {BlurhashProps} param1 Props of the component
* @returns {JSX.Element} Canvas which will render blurred region element to embed
*/
function Blurhash({
hash,
width = 32,
height = width,
dummy = false,
...canvasProps
}) {
const canvasRef = /** @type {import('react').MutableRefObject<HTMLCanvasElement>} */ (useRef());
useEffect(() => {
const { current: canvas } = canvasRef;
canvas.width = canvas.width; // resets canvas
if (dummy || !hash) return;
try {
const pixels = decode(hash, width, height);
const ctx = canvas.getContext('2d');
const imageData = new ImageData(pixels, width, height);
// @ts-expect-error
ctx.putImageData(imageData, 0, 0);
} catch (err) {
console.error('Blurhash decoding failure', { err, hash });
}
}, [dummy, hash, width, height]);
return (
<canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
);
}
Blurhash.propTypes = {
hash: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
dummy: PropTypes.bool,
};
export default React.memo(Blurhash);

@ -0,0 +1,45 @@
import { decode } from 'blurhash';
import React, { useRef, useEffect } from 'react';
type Props = {
hash: string;
width?: number;
height?: number;
dummy?: boolean; // Whether dummy mode is enabled. If enabled, nothing is rendered and canvas left untouched
children?: never;
[key: string]: any;
}
function Blurhash({
hash,
width = 32,
height = width,
dummy = false,
...canvasProps
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const canvas = canvasRef.current!;
// eslint-disable-next-line no-self-assign
canvas.width = canvas.width; // resets canvas
if (dummy || !hash) return;
try {
const pixels = decode(hash, width, height);
const ctx = canvas.getContext('2d');
const imageData = new ImageData(pixels, width, height);
ctx?.putImageData(imageData, 0, 0);
} catch (err) {
console.error('Blurhash decoding failure', { err, hash });
}
}, [dummy, hash, width, height]);
return (
<canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
);
}
export default React.memo(Blurhash);

@ -21,7 +21,9 @@ export default class ColumnBackButton extends React.PureComponent {
if (onClick) {
onClick();
} else if (window.history && window.history.state) {
// Check if there is a previous page in the app to go back to per https://stackoverflow.com/a/70532858/9703201
// When upgrading to V6, check `location.key !== 'default'` instead per https://github.com/remix-run/history/blob/main/docs/api-reference.md#location
} else if (router.route.location.key) {
router.history.goBack();
} else {
router.history.push('/');

@ -1,34 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
import AnimatedNumber from 'mastodon/components/animated_number';
export default class IconButton extends React.PureComponent {
static propTypes = {
className: PropTypes.string,
title: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
onClick: PropTypes.func,
onMouseDown: PropTypes.func,
onKeyDown: PropTypes.func,
onKeyPress: PropTypes.func,
size: PropTypes.number,
active: PropTypes.bool,
expanded: PropTypes.bool,
style: PropTypes.object,
activeStyle: PropTypes.object,
disabled: PropTypes.bool,
inverted: PropTypes.bool,
animate: PropTypes.bool,
overlay: PropTypes.bool,
tabIndex: PropTypes.number,
counter: PropTypes.number,
obfuscateCount: PropTypes.bool,
href: PropTypes.string,
ariaHidden: PropTypes.bool,
};
import { Icon } from './icon';
import { AnimatedNumber } from './animated_number';
type Props = {
className?: string;
title: string;
icon: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
size: number;
active: boolean;
expanded?: boolean;
style?: React.CSSProperties;
activeStyle?: React.CSSProperties;
disabled: boolean;
inverted?: boolean;
animate: boolean;
overlay: boolean;
tabIndex: number;
counter?: number;
obfuscateCount?: boolean;
href?: string;
ariaHidden: boolean;
}
type States = {
activate: boolean,
deactivate: boolean,
}
export default class IconButton extends React.PureComponent<Props, States> {
static defaultProps = {
size: 18,
@ -45,7 +47,7 @@ export default class IconButton extends React.PureComponent {
deactivate: false,
};
componentWillReceiveProps (nextProps) {
UNSAFE_componentWillReceiveProps (nextProps: Props) {
if (!nextProps.animate) return;
if (this.props.active && !nextProps.active) {
@ -55,27 +57,27 @@ export default class IconButton extends React.PureComponent {
}
}
handleClick = (e) => {
handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault();
if (!this.props.disabled) {
if (!this.props.disabled && this.props.onClick != null) {
this.props.onClick(e);
}
};
handleKeyPress = (e) => {
handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
if (this.props.onKeyPress && !this.props.disabled) {
this.props.onKeyPress(e);
}
};
handleMouseDown = (e) => {
handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (!this.props.disabled && this.props.onMouseDown) {
this.props.onMouseDown(e);
}
};
handleKeyDown = (e) => {
handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
if (!this.props.disabled && this.props.onKeyDown) {
this.props.onKeyDown(e);
}
@ -132,7 +134,7 @@ export default class IconButton extends React.PureComponent {
</React.Fragment>
);
if (href && !this.prop) {
if (href != null) {
contents = (
<a href={href} target='_blank' rel='noopener noreferrer'>
{contents}

@ -81,12 +81,10 @@ class Item extends React.PureComponent {
render () {
const { attachment, lang, index, size, standalone, displayWidth, visible } = this.props;
let badges = [], thumbnail;
let width = 50;
let height = 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
if (size === 1) {
width = 100;
@ -96,45 +94,13 @@ class Item extends React.PureComponent {
height = 50;
}
if (size === 2) {
if (index === 0) {
right = '2px';
} else {
left = '2px';
}
} else if (size === 3) {
if (index === 0) {
right = '2px';
} else if (index > 0) {
left = '2px';
}
if (index === 1) {
bottom = '2px';
} else if (index > 1) {
top = '2px';
}
} else if (size === 4) {
if (index === 0 || index === 2) {
right = '2px';
}
if (index === 1 || index === 3) {
left = '2px';
}
if (index < 2) {
bottom = '2px';
} else {
top = '2px';
}
if (attachment.get('description')?.length > 0) {
badges.push(<span key='alt' className='media-gallery__gifv__label'>ALT</span>);
}
let thumbnail = '';
if (attachment.get('type') === 'unknown') {
return (
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} lang={lang} target='_blank' rel='noopener noreferrer'>
<Blurhash
hash={attachment.get('blurhash')}
@ -184,6 +150,8 @@ class Item extends React.PureComponent {
} else if (attachment.get('type') === 'gifv') {
const autoPlay = this.getAutoPlay();
badges.push(<span key='gif' className='media-gallery__gifv__label'>GIF</span>);
thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
<video
@ -201,14 +169,12 @@ class Item extends React.PureComponent {
loop
muted
/>
<span className='media-gallery__gifv__label'>GIF</span>
</div>
);
}
return (
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
<Blurhash
hash={attachment.get('blurhash')}
dummy={!useBlurhash}
@ -216,7 +182,14 @@ class Item extends React.PureComponent {
'media-gallery__preview--hidden': visible && this.state.loaded,
})}
/>
{visible && thumbnail}
{badges && (
<div className='media-gallery__item__badges'>
{badges}
</div>
)}
</div>
);
}
@ -313,7 +286,7 @@ class MediaGallery extends React.PureComponent {
}
render () {
const { media, lang, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props;
const { media, lang, intl, sensitive, defaultWidth, standalone, autoplay } = this.props;
const { visible } = this.state;
const width = this.state.width || defaultWidth;
@ -322,13 +295,9 @@ class MediaGallery extends React.PureComponent {
const style = {};
if (this.isFullSizeEligible() && (standalone || !cropImages)) {
if (width) {
style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
}
} else if (width) {
style.height = width / (16/9);
style.aspectRatio = `${this.props.media.getIn([0, 'meta', 'small', 'aspect'])}`;
} else {
style.height = height;
style.aspectRatio = '16 / 9';
}
const size = media.take(4).size;

@ -3,62 +3,22 @@ import PropTypes from 'prop-types';
import Icon from 'mastodon/components/icon';
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { FormattedMessage } from 'react-intl';
class PictureInPicturePlaceholder extends React.PureComponent {
static propTypes = {
width: PropTypes.number,
dispatch: PropTypes.func.isRequired,
};
state = {
width: this.props.width,
height: this.props.width && (this.props.width / (16/9)),
};
handleClick = () => {
const { dispatch } = this.props;
dispatch(removePictureInPicture());
};
setRef = c => {
this.node = c;
if (this.node) {
this._setDimensions();
}
};
_setDimensions () {
const width = this.node.offsetWidth;
const height = width / (16/9);
this.setState({ width, height });
}
componentDidMount () {
window.addEventListener('resize', this.handleResize, { passive: true });
}
componentWillUnmount () {
window.removeEventListener('resize', this.handleResize);
}
handleResize = debounce(() => {
if (this.node) {
this._setDimensions();
}
}, 250, {
trailing: true,
});
render () {
const { height } = this.state;
return (
<div ref={this.setRef} className='picture-in-picture-placeholder' style={{ height }} role='button' tabIndex={0} onClick={this.handleClick}>
<div className='picture-in-picture-placeholder' role='button' tabIndex={0} onClick={this.handleClick}>
<Icon id='window-restore' />
<FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' />
</div>

@ -411,7 +411,7 @@ class Status extends ImmutablePureComponent {
}
if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
media = <PictureInPicturePlaceholder />;
} else if (status.get('media_attachments').size > 0) {
if (this.props.muted) {
media = (
@ -460,12 +460,9 @@ class Status extends ImmutablePureComponent {
src={attachment.get('url')}
alt={attachment.get('description')}
lang={status.get('language')}
width={this.props.cachedMediaWidth}
height={110}
inline
sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo}
cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
@ -498,8 +495,6 @@ class Status extends ImmutablePureComponent {
onOpenMedia={this.handleOpenMedia}
card={status.get('card')}
compact
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
sensitive={status.get('sensitive')}
/>
);

@ -1,7 +1,7 @@
import React from 'react';
import { Provider } from 'react-redux';
import PropTypes from 'prop-types';
import configureStore from '../store/configureStore';
import { store } from '../store/configureStore';
import { hydrateStore } from '../actions/store';
import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales';
@ -12,8 +12,6 @@ import { fetchCustomEmojis } from '../actions/custom_emojis';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
const store = configureStore();
if (initialState) {
store.dispatch(hydrateStore(initialState));
}

@ -5,7 +5,7 @@ import { IntlProvider, addLocaleData } from 'react-intl';
import { Provider as ReduxProvider } from 'react-redux';
import { BrowserRouter, Route } from 'react-router-dom';
import { ScrollContext } from 'react-router-scroll-4';
import configureStore from 'mastodon/store/configureStore';
import { store } from 'mastodon/store/configureStore';
import UI from 'mastodon/features/ui';
import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
import { hydrateStore } from 'mastodon/actions/store';
@ -19,7 +19,6 @@ addLocaleData(localeData);
const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`;
export const store = configureStore();
const hydrateAction = hydrateStore(initialState);
store.dispatch(hydrateAction);

@ -384,7 +384,7 @@ class Audio extends React.PureComponent {
}
_getRadius () {
return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
return parseInt((this.state.height || this.props.height) / 2 - PADDING * this._getScaleCoefficient());
}
_getScaleCoefficient () {
@ -396,7 +396,7 @@ class Audio extends React.PureComponent {
}
_getCY() {
return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
return Math.floor((this.state.height || this.props.height) / 2);
}
_getAccentColor () {
@ -470,7 +470,7 @@ class Audio extends React.PureComponent {
}
return (
<div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
<div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), aspectRatio: '16 / 9' }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
<Blurhash
hash={blurhash}
@ -515,9 +515,16 @@ class Audio extends React.PureComponent {
{(revealed || editable) && <img
src={this.props.poster}
alt=''
width={(this._getRadius() - TICK_SIZE) * 2}
height={(this._getRadius() - TICK_SIZE) * 2}
style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
style={{
position: 'absolute',
left: '50%',
top: '50%',
height: `calc(${(100 - 2 * 100 * PADDING / 982)}% - ${TICK_SIZE * 2}px)`,
aspectRatio: '1',
transform: 'translate(-50%, -50%)',
borderRadius: '50%',
pointerEvents: 'none',
}}
/>}
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>

@ -8,7 +8,6 @@ import classnames from 'classnames';
import Icon from 'mastodon/components/icon';
import { useBlurhash } from 'mastodon/initial_state';
import Blurhash from 'mastodon/components/blurhash';
import { debounce } from 'lodash';
const IDNA_PREFIX = 'xn--';
@ -54,8 +53,6 @@ export default class Card extends React.PureComponent {
card: ImmutablePropTypes.map,
onOpenMedia: PropTypes.func.isRequired,
compact: PropTypes.bool,
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
sensitive: PropTypes.bool,
};
@ -64,7 +61,6 @@ export default class Card extends React.PureComponent {
};
state = {
width: this.props.defaultWidth || 280,
previewLoaded: false,
embedded: false,
revealed: !this.props.sensitive,
@ -87,24 +83,6 @@ export default class Card extends React.PureComponent {
window.removeEventListener('resize', this.handleResize);
}
_setDimensions () {
const width = this.node.offsetWidth;
if (this.props.cacheWidth) {
this.props.cacheWidth(width);
}
this.setState({ width });
}
handleResize = debounce(() => {
if (this.node) {
this._setDimensions();
}
}, 250, {
trailing: true,
});
handlePhotoClick = () => {
const { card, onOpenMedia } = this.props;
@ -138,10 +116,6 @@ export default class Card extends React.PureComponent {
setRef = c => {
this.node = c;
if (this.node) {
this._setDimensions();
}
};
handleImageLoad = () => {
@ -157,36 +131,31 @@ export default class Card extends React.PureComponent {
renderVideo () {
const { card } = this.props;
const content = { __html: addAutoPlay(card.get('html')) };
const { width } = this.state;
const ratio = card.get('width') / card.get('height');
const height = width / ratio;
return (
<div
ref={this.setRef}
className='status-card__image status-card-video'
dangerouslySetInnerHTML={content}
style={{ height }}
style={{ aspectRatio: `${card.get('width')} / ${card.get('height')}` }}
/>
);
}
render () {
const { card, compact } = this.props;
const { width, embedded, revealed } = this.state;
const { embedded, revealed } = this.state;
if (card === null) {
return null;
}
const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
const horizontal = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded;
const horizontal = (!compact && card.get('width') > card.get('height')) || card.get('type') !== 'link' || embedded;
const interactive = card.get('type') !== 'link';
const className = classnames('status-card', { horizontal, compact, interactive });
const title = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener noreferrer' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
const language = card.get('language') || '';
const ratio = card.get('width') / card.get('height');
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
const description = (
<div className='status-card__content' lang={language}>
@ -196,6 +165,14 @@ export default class Card extends React.PureComponent {
</div>
);
const thumbnailStyle = {
visibility: revealed? null : 'hidden',
};
if (horizontal) {
thumbnailStyle.aspectRatio = (compact && !embedded) ? '16 / 9' : `${card.get('width')} / ${card.get('height')}`;
}
let embed = '';
let canvas = (
<Blurhash
@ -206,7 +183,7 @@ export default class Card extends React.PureComponent {
dummy={!useBlurhash}
/>
);
let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
let thumbnail = <img src={card.get('image')} alt='' style={thumbnailStyle} onLoad={this.handleImageLoad} className='status-card__image-image' />;
let spoilerButton = (
<button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>

@ -362,7 +362,7 @@ class UI extends React.PureComponent {
if (layout !== this.props.layout) {
this.handleLayoutChange.cancel();
this.props.dispatch(changeLayout(layout));
this.props.dispatch(changeLayout({ layout }));
} else {
this.handleLayoutChange();
}

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { is } from 'immutable';
import { throttle, debounce } from 'lodash';
import { throttle } from 'lodash';
import classNames from 'classnames';
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
import { displayMedia, useBlurhash } from '../../initial_state';
@ -102,8 +102,6 @@ class Video extends React.PureComponent {
src: PropTypes.string.isRequired,
alt: PropTypes.string,
lang: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
sensitive: PropTypes.bool,
currentTime: PropTypes.number,
onOpenVideo: PropTypes.func,
@ -112,7 +110,6 @@ class Video extends React.PureComponent {
inline: PropTypes.bool,
editable: PropTypes.bool,
alwaysVisible: PropTypes.bool,
cacheWidth: PropTypes.func,
visible: PropTypes.bool,
onToggleVisibility: PropTypes.func,
deployPictureInPicture: PropTypes.func,
@ -135,7 +132,6 @@ class Video extends React.PureComponent {
volume: 0.5,
paused: true,
dragging: false,
containerWidth: this.props.width,
fullscreen: false,
hovered: false,
muted: false,
@ -144,24 +140,8 @@ class Video extends React.PureComponent {
setPlayerRef = c => {
this.player = c;
if (this.player) {
this._setDimensions();
}
};
_setDimensions () {
const width = this.player.offsetWidth;
if (this.props.cacheWidth) {
this.props.cacheWidth(width);
}
this.setState({
containerWidth: width,
});
}
setVideoRef = c => {
this.video = c;
@ -370,12 +350,10 @@ class Video extends React.PureComponent {
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
window.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleResize, { passive: true });
}
componentWillUnmount () {
window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleResize);
document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
@ -404,14 +382,6 @@ class Video extends React.PureComponent {
}
}
handleResize = debounce(() => {
if (this.player) {
this._setDimensions();
}
}, 250, {
trailing: true,
});
handleScroll = throttle(() => {
if (!this.video) {
return;
@ -525,17 +495,12 @@ class Video extends React.PureComponent {
render () {
const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, lang, detailed, sensitive, editable, blurhash, autoFocus } = this.props;
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
const { currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
const progress = Math.min((currentTime / duration) * 100, 100);
const playerStyle = {};
let { width, height } = this.props;
if (inline && containerWidth) {
width = containerWidth;
height = containerWidth / (16/9);
playerStyle.height = height;
if (inline) {
playerStyle.aspectRatio = '16 / 9';
}
let preload;
@ -586,8 +551,6 @@ class Video extends React.PureComponent {
aria-label={alt}
title={alt}
lang={lang}
width={width}
height={height}
volume={volume}
onClick={this.togglePlay}
onKeyDown={this.handleVideoKeyDown}
@ -596,6 +559,7 @@ class Video extends React.PureComponent {
onLoadedData={this.handleLoadedData}
onProgress={this.handleProgress}
onVolumeChange={this.handleVolumeChange}
style={{ ...playerStyle, width: '100%' }}
/>}
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>

@ -1,21 +1,12 @@
// @ts-check
import { supportsPassiveEvents } from 'detect-passive-events';
// @ts-expect-error
import { forceSingleColumn } from 'mastodon/initial_state';
import { forceSingleColumn } from './initial_state';
const LAYOUT_BREAKPOINT = 630;
/**
* @param {number} width
* @returns {boolean}
*/
export const isMobile = width => width <= LAYOUT_BREAKPOINT;
export const isMobile = (width: number) => width <= LAYOUT_BREAKPOINT;
/**
* @returns {string}
*/
export const layoutFromWindow = () => {
export type LayoutType = 'mobile' | 'single-column' | 'multi-column';
export const layoutFromWindow = (): LayoutType => {
if (isMobile(window.innerWidth)) {
return 'mobile';
} else if (forceSingleColumn) {
@ -25,8 +16,9 @@ export const layoutFromWindow = () => {
}
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && window.MSStream != null;
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;

@ -1,7 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { setupBrowserNotifications } from 'mastodon/actions/notifications';
import Mastodon, { store } from 'mastodon/containers/mastodon';
import Mastodon from 'mastodon/containers/mastodon';
import { store } from 'mastodon/store/configureStore';
import { me } from 'mastodon/initial_state';
import ready from 'mastodon/ready';

@ -1,5 +1,5 @@
import { STORE_HYDRATE } from 'mastodon/actions/store';
import { APP_LAYOUT_CHANGE } from 'mastodon/actions/app';
import { changeLayout } from 'mastodon/actions/app';
import { Map as ImmutableMap } from 'immutable';
import { layoutFromWindow } from 'mastodon/is_mobile';
@ -14,8 +14,8 @@ export default function meta(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
return state.merge(action.state.get('meta')).set('permissions', action.state.getIn(['role', 'permissions']));
case APP_LAYOUT_CHANGE:
return state.set('layout', action.layout);
case changeLayout.type:
return state.set('layout', action.payload.layout);
default:
return state;
}

@ -1,21 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import { NOTIFICATIONS_UPDATE } from 'mastodon/actions/notifications';
import { APP_FOCUS, APP_UNFOCUS } from 'mastodon/actions/app';
const initialState = ImmutableMap({
focused: true,
unread: 0,
});
export default function missed_updates(state = initialState, action) {
switch(action.type) {
case APP_FOCUS:
return state.set('focused', true).set('unread', 0);
case APP_UNFOCUS:
return state.set('focused', false);
case NOTIFICATIONS_UPDATE:
return state.get('focused') ? state : state.update('unread', x => x + 1);
default:
return state;
}
}

@ -0,0 +1,31 @@
import { Record } from 'immutable';
import type { Action } from 'redux';
import { NOTIFICATIONS_UPDATE } from '../actions/notifications';
import { focusApp, unfocusApp } from '../actions/app';
type MissedUpdatesState = {
focused: boolean;
unread: number;
};
const initialState = Record<MissedUpdatesState>({
focused: true,
unread: 0,
})();
export default function missed_updates(
state = initialState,
action: Action<string>,
) {
switch (action.type) {
case focusApp.type:
return state.set('focused', true).set('unread', 0);
case unfocusApp.type:
return state.set('focused', false);
case NOTIFICATIONS_UPDATE:
return state.get('focused')
? state
: state.update('unread', (x) => x + 1);
default:
return state;
}
}

@ -23,8 +23,8 @@ import {
MARKERS_FETCH_SUCCESS,
} from '../actions/markers';
import {
APP_FOCUS,
APP_UNFOCUS,
focusApp,
unfocusApp,
} from '../actions/app';
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
@ -258,9 +258,9 @@ export default function notifications(state = initialState, action) {
return updateMounted(state);
case NOTIFICATIONS_UNMOUNT:
return state.update('mounted', count => count - 1);
case APP_FOCUS:
case focusApp.type:
return updateVisibility(state, true);
case APP_UNFOCUS:
case unfocusApp.type:
return updateVisibility(state, false);
case NOTIFICATIONS_LOAD_PENDING:
return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0);

@ -1,6 +1,5 @@
const easingOutQuint = (x, t, b, c, d) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
const scroll = (node, key, target) => {
const easingOutQuint = (x: number, t: number, b: number, c: number, d: number) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
const scroll = (node: Element, key: 'scrollTop' | 'scrollLeft', target: number) => {
const startTime = Date.now();
const offset = node[key];
const gap = target - offset;
@ -28,5 +27,5 @@ const scroll = (node, key, target) => {
const isScrollBehaviorSupported = 'scrollBehavior' in document.documentElement.style;
export const scrollRight = (node, position) => isScrollBehaviorSupported ? node.scrollTo({ left: position, behavior: 'smooth' }) : scroll(node, 'scrollLeft', position);
export const scrollTop = (node) => isScrollBehaviorSupported ? node.scrollTo({ top: 0, behavior: 'smooth' }) : scroll(node, 'scrollTop', 0);
export const scrollRight = (node: Element, position: number) => isScrollBehaviorSupported ? node.scrollTo({ left: position, behavior: 'smooth' }) : scroll(node, 'scrollLeft', position);
export const scrollTop = (node: Element) => isScrollBehaviorSupported ? node.scrollTo({ top: 0, behavior: 'smooth' }) : scroll(node, 'scrollTop', 0);

@ -1,15 +1,16 @@
import { createStore, applyMiddleware, compose } from 'redux';
import { configureStore } from '@reduxjs/toolkit';
import thunk from 'redux-thunk';
import appReducer from '../reducers';
import loadingBarMiddleware from '../middleware/loading_bar';
import errorsMiddleware from '../middleware/errors';
import soundsMiddleware from '../middleware/sounds';
export default function configureStore() {
return createStore(appReducer, compose(applyMiddleware(
export const store = configureStore({
reducer: appReducer,
middleware: [
thunk,
loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
errorsMiddleware(),
soundsMiddleware(),
), window.__REDUX_DEVTOOLS_EXTENSION__ ? window.__REDUX_DEVTOOLS_EXTENSION__() : f => f));
}
],
});

@ -1,4 +1,4 @@
export const decode = base64 => {
export const decode = (base64: string): Uint8Array => {
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);

@ -1,4 +1,4 @@
export const toServerSideType = columnType => {
export const toServerSideType = (columnType: string) => {
switch (columnType) {
case 'home':
case 'notifications':

@ -1,23 +1,19 @@
// @ts-check
import type { ValueOf } from '../../types/util';
export const DECIMAL_UNITS = Object.freeze({
ONE: 1,
TEN: 10,
HUNDRED: Math.pow(10, 2),
THOUSAND: Math.pow(10, 3),
MILLION: Math.pow(10, 6),
BILLION: Math.pow(10, 9),
TRILLION: Math.pow(10, 12),
HUNDRED: 100,
THOUSAND: 1_000,
MILLION: 1_000_000,
BILLION: 1_000_000_000,
TRILLION: 1_000_000_000_000,
});
export type DecimalUnits = ValueOf<typeof DECIMAL_UNITS>;
const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10;
const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10;
/**
* @typedef {[number, number, number]} ShortNumber
* Array of: shorten number, unit of shorten number and maximum fraction digits
*/
/**
* @param {number} sourceNumber Number to convert to short number
* @returns {ShortNumber} Calculated short number
@ -25,7 +21,8 @@ const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10;
* shortNumber(5936);
* // => [5.936, 1000, 1]
*/
export function toShortNumber(sourceNumber) {
export type ShortNumber = [number, DecimalUnits, 0 | 1] // Array of: shorten number, unit of shorten number and maximum fraction digits
export function toShortNumber(sourceNumber: number): ShortNumber {
if (sourceNumber < DECIMAL_UNITS.THOUSAND) {
return [sourceNumber, DECIMAL_UNITS.ONE, 0];
} else if (sourceNumber < DECIMAL_UNITS.MILLION) {
@ -59,20 +56,16 @@ export function toShortNumber(sourceNumber) {
* pluralReady(1793, DECIMAL_UNITS.THOUSAND)
* // => 1790
*/
export function pluralReady(sourceNumber, division) {
export function pluralReady(sourceNumber: number, division: DecimalUnits): number {
if (division == null || division < DECIMAL_UNITS.HUNDRED) {
return sourceNumber;
}
let closestScale = division / DECIMAL_UNITS.TEN;
const closestScale = division / DECIMAL_UNITS.TEN;
return Math.trunc(sourceNumber / closestScale) * closestScale;
}
/**
* @param {number} num
* @returns {number}
*/
export function roundTo10(num) {
export function roundTo10(num: number): number {
return Math.round(num * 0.1) / 0.1;
}

@ -1784,7 +1784,6 @@ a.account__display-name {
.status__avatar {
width: 46px;
height: 46px;
box-shadow: 0 0 0 2px $ui-base-color;
}
.muted {
@ -3110,6 +3109,10 @@ $ui-header-height: 55px;
}
.compose-form__highlightable {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 0 1 auto;
border-radius: 4px;
transition: box-shadow 300ms linear;
@ -3804,6 +3807,10 @@ a.status-card {
}
.status-card-video {
// Firefox has a bug where frameborder=0 iframes add some extra blank space
// see https://bugzilla.mozilla.org/show_bug.cgi?id=155174
overflow: hidden;
iframe {
width: 100%;
height: 100%;
@ -6326,32 +6333,27 @@ a.status-card.compact:hover {
z-index: 9999;
}
.media-gallery__gifv__label {
display: block;
.media-gallery__item__badges {
position: absolute;
color: $primary-text-color;
background: rgba($base-overlay-background, 0.5);
bottom: 6px;
inset-inline-start: 6px;
display: flex;
gap: 2px;
}
.media-gallery__gifv__label {
display: block;
color: $white;
background: rgba($black, 0.65);
padding: 2px 6px;
border-radius: 2px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
font-weight: 700;
z-index: 1;
pointer-events: none;
opacity: 0.9;
transition: opacity 0.1s ease;
line-height: 18px;
}
.media-gallery__gifv {
&:hover {
.media-gallery__gifv__label {
opacity: 1;
}
}
}
.attachment-list {
display: flex;
font-size: 14px;
@ -6424,17 +6426,28 @@ a.status-card.compact:hover {
position: relative;
width: 100%;
min-height: 64px;
display: grid;
grid-template-columns: 50% 50%;
grid-template-rows: 50% 50%;
gap: 2px;
}
.media-gallery__item {
border: 0;
box-sizing: border-box;
display: block;
float: left;
position: relative;
border-radius: 4px;
overflow: hidden;
&--tall {
grid-row: span 2;
}
&--wide {
grid-column: span 2;
}
&.standalone {
.media-gallery__item-gifv-thumbnail {
transform: none;
@ -8332,6 +8345,7 @@ noscript {
font-weight: 500;
cursor: pointer;
color: $darker-text-color;
aspect-ratio: 16 / 9;
i {
display: block;

@ -1,8 +1,4 @@
interface MastodonMap<T> {
get<K extends keyof T>(key: K): T[K];
has<K extends keyof T>(key: K): boolean;
set<K extends keyof T>(key: K, value: T[K]): this;
}
import type { Record } from 'immutable';
type AccountValues = {
id: number;
@ -10,4 +6,5 @@ type AccountValues = {
avatar_static: string;
[key: string]: any;
};
export type Account = MastodonMap<AccountValues>;
export type Account = Record<AccountValues>;

@ -0,0 +1 @@
export type ValueOf<T> = T[keyof T];

@ -43,7 +43,7 @@ class ActivityTracker
case @type
when :basic
redis.mget(*keys).map(&:to_i).sum
redis.mget(*keys).sum(&:to_i)
when :unique
redis.pfcount(*keys)
end

@ -4,7 +4,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
def perform
return reject_payload! if delete_arrived_first?(@json['id']) || !related_to_local_activity?
with_lock("announce:#{value_or_id(@object)}") do
with_redis_lock("announce:#{value_or_id(@object)}") do
original_status = status_from_object
return reject_payload! if original_status.nil? || !announceable?(original_status)

@ -47,7 +47,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def create_status
return reject_payload! if unsupported_object_type? || non_matching_uri_hosts?(@account.uri, object_uri) || tombstone_exists? || !related_to_local_activity?
with_lock("create:#{object_uri}") do
with_redis_lock("create:#{object_uri}") do
return if delete_arrived_first?(object_uri) || poll_vote?
@status = find_existing_status
@ -313,7 +313,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
poll = replied_to_status.preloadable_poll
already_voted = true
with_lock("vote:#{replied_to_status.poll_id}:#{@account.id}") do
with_redis_lock("vote:#{replied_to_status.poll_id}:#{@account.id}") do
already_voted = poll.votes.where(account: @account).exists?
poll.votes.create!(account: @account, choice: poll.options.index(@object['name']), uri: object_uri)
end

@ -12,7 +12,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
private
def delete_person
with_lock("delete_in_progress:#{@account.id}", autorelease: 2.hours, raise_on_failure: false) do
with_redis_lock("delete_in_progress:#{@account.id}", autorelease: 2.hours, raise_on_failure: false) do
DeleteAccountService.new.call(@account, reserve_username: false, skip_activitypub: true)
end
end
@ -20,14 +20,14 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
def delete_note
return if object_uri.nil?
with_lock("delete_status_in_progress:#{object_uri}", raise_on_failure: false) do
with_redis_lock("delete_status_in_progress:#{object_uri}", raise_on_failure: false) do
unless non_matching_uri_hosts?(@account.uri, object_uri)
# This lock ensures a concurrent `ActivityPub::Activity::Create` either
# does not create a status at all, or has finished saving it to the
# database before we try to load it.
# Without the lock, `delete_later!` could be called after `delete_arrived_first?`
# and `Status.find` before `Status.create!`
with_lock("create:#{object_uri}") { delete_later!(object_uri) }
with_redis_lock("create:#{object_uri}") { delete_later!(object_uri) }
Tombstone.find_or_create_by(uri: object_uri, account: @account)
end

@ -13,7 +13,7 @@ module ActivityPub::CaseTransform
when Symbol then camel_lower(value.to_s).to_sym
when String
camel_lower_cache[value] ||= if value.start_with?('_:')
"_:#{value.gsub(/\A_:/, '').underscore.camelize(:lower)}"
"_:#{value.delete_prefix('_:').underscore.camelize(:lower)}"
else
value.underscore.camelize(:lower)
end

@ -407,10 +407,10 @@ class FeedManager
return true if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language)
check_for_blocks = crutches[:active_mentions][status.id] || []
check_for_blocks.concat([status.account_id])
check_for_blocks.push(status.account_id)
if status.reblog?
check_for_blocks.concat([status.reblog.account_id])
check_for_blocks.push(status.reblog.account_id)
check_for_blocks.concat(crutches[:active_mentions][status.reblog_of_id] || [])
end
@ -446,7 +446,7 @@ class FeedManager
# the notification has been checked for mute/block. Therefore, it's not
# necessary to check the author of the toot for mute/block again
check_for_blocks = status.active_mentions.pluck(:account_id)
check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
check_for_blocks.push(status.in_reply_to_account) if status.reply? && !status.in_reply_to_account_id.nil?
should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
@ -593,10 +593,10 @@ class FeedManager
check_for_blocks = statuses.flat_map do |s|
arr = crutches[:active_mentions][s.id] || []
arr.concat([s.account_id])
arr.push(s.account_id)
if s.reblog?
arr.concat([s.reblog.account_id])
arr.push(s.reblog.account_id)
arr.concat(crutches[:active_mentions][s.reblog_of_id] || [])
end

@ -6,8 +6,8 @@ class Importer::AccountsIndexImporter < Importer::BaseImporter
in_work_unit(tmp) do |accounts|
bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: accounts).bulk_body
indexed = bulk.select { |entry| entry[:index] }.size
deleted = bulk.select { |entry| entry[:delete] }.size
indexed = bulk.count { |entry| entry[:index] }
deleted = bulk.count { |entry| entry[:delete] }
Chewy::Index::Import::BulkRequest.new(index).perform(bulk)

@ -6,8 +6,8 @@ class Importer::TagsIndexImporter < Importer::BaseImporter
in_work_unit(tmp) do |tags|
bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: tags).bulk_body
indexed = bulk.select { |entry| entry[:index] }.size
deleted = bulk.select { |entry| entry[:delete] }.size
indexed = bulk.count { |entry| entry[:index] }
deleted = bulk.count { |entry| entry[:delete] }
Chewy::Index::Import::BulkRequest.new(index).perform(bulk)

@ -8,21 +8,51 @@ class PermalinkRedirector
end
def redirect_path
if path_segments[0].present? && path_segments[0].start_with?('@') && path_segments[1] =~ /\d/
find_status_url_by_id(path_segments[1])
elsif path_segments[0].present? && path_segments[0].start_with?('@')
find_account_url_by_name(path_segments[0])
elsif path_segments[0] == 'statuses' && path_segments[1] =~ /\d/
find_status_url_by_id(path_segments[1])
elsif path_segments[0] == 'accounts' && path_segments[1] =~ /\d/
find_account_url_by_id(path_segments[1])
if at_username_status_request? || statuses_status_request?
find_status_url_by_id(second_segment)
elsif at_username_request?
find_account_url_by_name(first_segment)
elsif accounts_request? && record_integer_id_request?
find_account_url_by_id(second_segment)
end
end
private
def at_username_status_request?
at_username_request? && record_integer_id_request?
end
def statuses_status_request?
statuses_request? && record_integer_id_request?
end
def at_username_request?
first_segment.present? && first_segment.start_with?('@')
end
def statuses_request?
first_segment == 'statuses'
end
def accounts_request?
first_segment == 'accounts'
end
def record_integer_id_request?
second_segment =~ /\d/
end
def first_segment
path_segments.first
end
def second_segment
path_segments.second
end
def path_segments
@path_segments ||= @path.gsub(/\A\//, '').split('/')
@path_segments ||= @path.delete_prefix('/').split('/')
end
def find_status_url_by_id(id)

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Vacuum::ImportsVacuum
def perform
clean_unconfirmed_imports!
clean_old_imports!
end
private
def clean_unconfirmed_imports!
BulkImport.where(state: :unconfirmed).where('created_at <= ?', 10.minutes.ago).reorder(nil).in_batches.delete_all
end
def clean_old_imports!
BulkImport.where('created_at <= ?', 1.week.ago).reorder(nil).in_batches.delete_all
end
end

@ -57,7 +57,7 @@ class WebfingerResource
end
def resource_without_acct_string
resource.gsub(/\Aacct:/, '')
resource.delete_prefix('acct:')
end
def local_username

@ -78,6 +78,7 @@ class Account < ApplicationRecord
include DomainNormalizable
include DomainMaterializable
include AccountMerging
include AccountSearch
MAX_DISPLAY_NAME_LENGTH = (ENV['MAX_DISPLAY_NAME_CHARS'] || 30).to_i
MAX_NOTE_LENGTH = (ENV['MAX_BIO_CHARS'] || 500).to_i
@ -408,14 +409,6 @@ class Account < ApplicationRecord
end
class << self
DISALLOWED_TSQUERY_CHARACTERS = /['?\\:]/
TEXTSEARCH = "(setweight(to_tsvector('simple', accounts.display_name), 'A') || setweight(to_tsvector('simple', accounts.username), 'B') || setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C'))"
REPUTATION_SCORE_FUNCTION = '(greatest(0, coalesce(s.followers_count, 0)) / (greatest(0, coalesce(s.following_count, 0)) + 1.0))'
FOLLOWERS_SCORE_FUNCTION = 'log(greatest(0, coalesce(s.followers_count, 0)) + 2)'
TIME_DISTANCE_FUNCTION = '(case when s.last_status_at is null then 0 else exp(-1.0 * ((greatest(0, abs(extract(DAY FROM age(s.last_status_at))) - 30.0)^2) / (2.0 * ((-1.0 * 30^2) / (2.0 * ln(0.3)))))) end)'
BOOST = "((#{REPUTATION_SCORE_FUNCTION} + #{FOLLOWERS_SCORE_FUNCTION} + #{TIME_DISTANCE_FUNCTION}) / 3.0)"
def readonly_attributes
super - %w(statuses_count following_count followers_count)
end
@ -425,37 +418,6 @@ class Account < ApplicationRecord
DeliveryFailureTracker.without_unavailable(urls)
end
def search_for(terms, limit: 10, offset: 0)
tsquery = generate_query_for_search(terms)
sql = <<-SQL.squish
SELECT
accounts.*,
#{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
FROM accounts
LEFT JOIN users ON accounts.id = users.account_id
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
AND accounts.suspended_at IS NULL
AND accounts.moved_to_account_id IS NULL
AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
ORDER BY rank DESC
LIMIT :limit OFFSET :offset
SQL
records = find_by_sql([sql, limit: limit, offset: offset, tsquery: tsquery])
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
records
end
def advanced_search_for(terms, account, limit: 10, following: false, offset: 0)
tsquery = generate_query_for_search(terms)
sql = advanced_search_for_sql_template(following)
records = find_by_sql([sql, id: account.id, limit: limit, offset: offset, tsquery: tsquery])
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
records
end
def from_text(text)
return [] if text.blank?
@ -469,73 +431,15 @@ class Account < ApplicationRecord
EntityCache.instance.mention(username, domain)
end
end
private
def generate_query_for_search(unsanitized_terms)
terms = unsanitized_terms.gsub(DISALLOWED_TSQUERY_CHARACTERS, ' ')
# The final ":*" is for prefix search.
# The trailing space does not seem to fit any purpose, but `to_tsquery`
# behaves differently with and without a leading space if the terms start
# with `./`, `../`, or `.. `. I don't understand why, so, in doubt, keep
# the same query.
"' #{terms} ':*"
end
def advanced_search_for_sql_template(following)
if following
<<-SQL.squish
WITH first_degree AS (
SELECT target_account_id
FROM follows
WHERE account_id = :id
UNION ALL
SELECT :id
)
SELECT
accounts.*,
(count(f.id) + 1) * #{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
FROM accounts
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id)
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
WHERE accounts.id IN (SELECT * FROM first_degree)
AND to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
AND accounts.suspended_at IS NULL
AND accounts.moved_to_account_id IS NULL
GROUP BY accounts.id, s.id
ORDER BY rank DESC
LIMIT :limit OFFSET :offset
SQL
else
<<-SQL.squish
SELECT
accounts.*,
#{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank,
count(f.id) AS followed
FROM accounts
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id) OR (accounts.id = f.target_account_id AND f.account_id = :id)
LEFT JOIN users ON accounts.id = users.account_id
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
AND accounts.suspended_at IS NULL
AND accounts.moved_to_account_id IS NULL
AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
GROUP BY accounts.id, s.id
ORDER BY followed DESC, rank DESC
LIMIT :limit OFFSET :offset
SQL
end
end
end
def emojis
@emojis ||= CustomEmoji.from_text(emojifiable_text, domain)
end
before_create :generate_keys
before_validation :prepare_contents, if: :local?
before_validation :prepare_username, on: :create
before_create :generate_keys
before_destroy :clean_feed_manager
def ensure_keys!

@ -17,14 +17,13 @@
class AccountConversation < ApplicationRecord
include Redisable
before_validation :set_last_status
after_commit :push_to_streaming_api
belongs_to :account
belongs_to :conversation
belongs_to :last_status, class_name: 'Status'
before_validation :set_last_status
def participant_account_ids=(arr)
self[:participant_account_ids] = arr.sort
end

@ -42,7 +42,7 @@ class AccountMigration < ApplicationRecord
return false unless errors.empty?
with_lock("account_migration:#{account.id}") do
with_redis_lock("account_migration:#{account.id}") do
save
end
end

@ -32,9 +32,9 @@ class AccountStatusesFilter
private
def initial_scope
if suspended?
Status.none
elsif anonymous?
return Status.none if suspended?
if anonymous?
account.statuses.not_local_only.where(visibility: %i(public unlisted))
elsif author?
account.statuses.all # NOTE: #merge! does not work without the #all

@ -18,7 +18,7 @@ class AccountSuggestions::Source
def as_ordered_suggestions(scope, ordered_list)
return [] if ordered_list.empty?
map = scope.index_by(&method(:to_ordered_list_key))
map = scope.index_by { |account| to_ordered_list_key(account) }
ordered_list.map { |ordered_list_key| map[ordered_list_key] }.compact.map do |account|
AccountSuggestions::Suggestion.new(

@ -5,6 +5,8 @@ class Admin::AppealFilter
status
).freeze
IGNORED_PARAMS = %w(page).freeze
attr_reader :params
def initialize(params)
@ -15,7 +17,7 @@ class Admin::AppealFilter
scope = Appeal.order(id: :desc)
params.each do |key, value|
next if %w(page).include?(key.to_s)
next if IGNORED_PARAMS.include?(key.to_s)
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
end

@ -6,6 +6,8 @@ class Admin::StatusFilter
report_id
).freeze
IGNORED_PARAMS = %w(page report_id).freeze
attr_reader :params
def initialize(account, params)
@ -17,7 +19,7 @@ class Admin::StatusFilter
scope = @account.statuses.where(visibility: [:public, :unlisted])
params.each do |key, value|
next if %w(page report_id).include?(key.to_s)
next if IGNORED_PARAMS.include?(key.to_s)
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
end

@ -14,6 +14,7 @@
#
class AnnouncementReaction < ApplicationRecord
before_validation :set_custom_emoji
after_commit :queue_publish
belongs_to :account
@ -23,8 +24,6 @@ class AnnouncementReaction < ApplicationRecord
validates :name, presence: true
validates_with ReactionValidator
before_validation :set_custom_emoji
private
def set_custom_emoji

@ -25,8 +25,8 @@ class Block < ApplicationRecord
false # Force uri_for to use uri attribute
end
after_commit :remove_blocking_cache
before_validation :set_uri, only: :create
after_commit :remove_blocking_cache
private

@ -0,0 +1,53 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: bulk_imports
#
# id :bigint(8) not null, primary key
# type :integer not null
# state :integer not null
# total_items :integer default(0), not null
# imported_items :integer default(0), not null
# processed_items :integer default(0), not null
# finished_at :datetime
# overwrite :boolean default(FALSE), not null
# likely_mismatched :boolean default(FALSE), not null
# original_filename :string default(""), not null
# account_id :bigint(8) not null
# created_at :datetime not null
# updated_at :datetime not null
#
class BulkImport < ApplicationRecord
self.inheritance_column = false
belongs_to :account
has_many :rows, class_name: 'BulkImportRow', inverse_of: :bulk_import, dependent: :delete_all
enum type: {
following: 0,
blocking: 1,
muting: 2,
domain_blocking: 3,
bookmarks: 4,
}
enum state: {
unconfirmed: 0,
scheduled: 1,
in_progress: 2,
finished: 3,
}
validates :type, presence: true
def self.progress!(bulk_import_id, imported: false)
# Use `increment_counter` so that the incrementation is done atomically in the database
BulkImport.increment_counter(:processed_items, bulk_import_id) # rubocop:disable Rails/SkipsModelValidations
BulkImport.increment_counter(:imported_items, bulk_import_id) if imported # rubocop:disable Rails/SkipsModelValidations
# Since the incrementation has been done atomically, concurrent access to `bulk_import` is now bening
bulk_import = BulkImport.find(bulk_import_id)
bulk_import.update!(state: :finished, finished_at: Time.now.utc) if bulk_import.processed_items == bulk_import.total_items
end
end

@ -0,0 +1,15 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: bulk_import_rows
#
# id :bigint(8) not null, primary key
# bulk_import_id :bigint(8) not null
# data :jsonb
# created_at :datetime not null
# updated_at :datetime not null
#
class BulkImportRow < ApplicationRecord
belongs_to :bulk_import
end

@ -68,5 +68,8 @@ module AccountAssociations
# Account statuses cleanup policy
has_one :statuses_cleanup_policy, class_name: 'AccountStatusesCleanupPolicy', inverse_of: :account, dependent: :destroy
# Imports
has_many :bulk_imports, inverse_of: :account, dependent: :delete_all
end
end

@ -271,7 +271,8 @@ module AccountInteractions
end
def lists_for_local_distribution
lists.joins(account: :user)
scope = lists.joins(account: :user)
scope.where.not(list_accounts: { follow_id: nil }).or(scope.where(account_id: id))
.where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
end

@ -0,0 +1,140 @@
# frozen_string_literal: true
module AccountSearch
extend ActiveSupport::Concern
DISALLOWED_TSQUERY_CHARACTERS = /['?\\:]/
TEXT_SEARCH_RANKS = <<~SQL.squish
(
setweight(to_tsvector('simple', accounts.display_name), 'A') ||
setweight(to_tsvector('simple', accounts.username), 'B') ||
setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C')
)
SQL
REPUTATION_SCORE_FUNCTION = <<~SQL.squish
(
greatest(0, coalesce(s.followers_count, 0)) / (
greatest(0, coalesce(s.following_count, 0)) + 1.0
)
)
SQL
FOLLOWERS_SCORE_FUNCTION = <<~SQL.squish
log(
greatest(0, coalesce(s.followers_count, 0)) + 2
)
SQL
TIME_DISTANCE_FUNCTION = <<~SQL.squish
(
case
when s.last_status_at is null then 0
else exp(
-1.0 * (
(
greatest(0, abs(extract(DAY FROM age(s.last_status_at))) - 30.0)^2) /#{' '}
(2.0 * ((-1.0 * 30^2) / (2.0 * ln(0.3)))
)
)
)
end
)
SQL
BOOST = <<~SQL.squish
(
(#{REPUTATION_SCORE_FUNCTION} + #{FOLLOWERS_SCORE_FUNCTION} + #{TIME_DISTANCE_FUNCTION}) / 3.0
)
SQL
BASIC_SEARCH_SQL = <<~SQL.squish
SELECT
accounts.*,
#{BOOST} * ts_rank_cd(#{TEXT_SEARCH_RANKS}, to_tsquery('simple', :tsquery), 32) AS rank
FROM accounts
LEFT JOIN users ON accounts.id = users.account_id
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
WHERE to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS}
AND accounts.suspended_at IS NULL
AND accounts.moved_to_account_id IS NULL
AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
ORDER BY rank DESC
LIMIT :limit OFFSET :offset
SQL
ADVANCED_SEARCH_WITH_FOLLOWING = <<~SQL.squish
WITH first_degree AS (
SELECT target_account_id
FROM follows
WHERE account_id = :id
UNION ALL
SELECT :id
)
SELECT
accounts.*,
(count(f.id) + 1) * #{BOOST} * ts_rank_cd(#{TEXT_SEARCH_RANKS}, to_tsquery('simple', :tsquery), 32) AS rank
FROM accounts
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id)
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
WHERE accounts.id IN (SELECT * FROM first_degree)
AND to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS}
AND accounts.suspended_at IS NULL
AND accounts.moved_to_account_id IS NULL
GROUP BY accounts.id, s.id
ORDER BY rank DESC
LIMIT :limit OFFSET :offset
SQL
ADVANCED_SEARCH_WITHOUT_FOLLOWING = <<~SQL.squish
SELECT
accounts.*,
#{BOOST} * ts_rank_cd(#{TEXT_SEARCH_RANKS}, to_tsquery('simple', :tsquery), 32) AS rank,
count(f.id) AS followed
FROM accounts
LEFT OUTER JOIN follows AS f ON
(accounts.id = f.account_id AND f.target_account_id = :id) OR (accounts.id = f.target_account_id AND f.account_id = :id)
LEFT JOIN users ON accounts.id = users.account_id
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
WHERE to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS}
AND accounts.suspended_at IS NULL
AND accounts.moved_to_account_id IS NULL
AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
GROUP BY accounts.id, s.id
ORDER BY followed DESC, rank DESC
LIMIT :limit OFFSET :offset
SQL
class_methods do
def search_for(terms, limit: 10, offset: 0)
tsquery = generate_query_for_search(terms)
find_by_sql([BASIC_SEARCH_SQL, { limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
end
end
def advanced_search_for(terms, account, limit: 10, following: false, offset: 0)
tsquery = generate_query_for_search(terms)
sql_template = following ? ADVANCED_SEARCH_WITH_FOLLOWING : ADVANCED_SEARCH_WITHOUT_FOLLOWING
find_by_sql([sql_template, { id: account.id, limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
end
end
private
def generate_query_for_search(unsanitized_terms)
terms = unsanitized_terms.gsub(DISALLOWED_TSQUERY_CHARACTERS, ' ')
# The final ":*" is for prefix search.
# The trailing space does not seem to fit any purpose, but `to_tsquery`
# behaves differently with and without a leading space if the terms start
# with `./`, `../`, or `.. `. I don't understand why, so, in doubt, keep
# the same query.
"' #{terms} ':*"
end
end
end

@ -5,7 +5,7 @@ module Lockable
# @param [ActiveSupport::Duration] autorelease Automatically release the lock after this time
# @param [Boolean] raise_on_failure Raise an error if a lock cannot be acquired, or fail silently
# @raise [Mastodon::RaceConditionError]
def with_lock(lock_name, autorelease: 15.minutes, raise_on_failure: true)
def with_redis_lock(lock_name, autorelease: 15.minutes, raise_on_failure: true)
with_redis do |redis|
RedisLock.acquire(redis: redis, key: "lock:#{lock_name}", autorelease: autorelease.seconds) do |lock|
if lock.acquired?

@ -0,0 +1,72 @@
# frozen_string_literal: true
module StatusSafeReblogInsert
extend ActiveSupport::Concern
class_methods do
# This is a hack to ensure that no reblogs of discarded statuses are created,
# as this cannot be enforced through database constraints the same way we do
# for reblogs of deleted statuses.
#
# To achieve this, we redefine the internal method responsible for issuing
# the "INSERT" statement and replace the "INSERT INTO ... VALUES ..." query
# with an "INSERT INTO ... SELECT ..." query with a "WHERE deleted_at IS NULL"
# clause on the reblogged status to ensure consistency at the database level.
#
# Otherwise, the code is kept as close as possible to ActiveRecord::Persistence
# code, and actually calls it if we are not handling a reblog.
def _insert_record(values)
return super unless values.is_a?(Hash) && values['reblog_of_id'].present?
primary_key = self.primary_key
primary_key_value = nil
if primary_key
primary_key_value = values[primary_key]
if !primary_key_value && prefetch_primary_key?
primary_key_value = next_sequence_value
values[primary_key] = primary_key_value
end
end
# The following line is where we differ from stock ActiveRecord implementation
im = _compile_reblog_insert(values)
# Since we are using SELECT instead of VALUES, a non-error `nil` return is possible.
# For our purposes, it's equivalent to a foreign key constraint violation
result = connection.insert(im, "#{self} Create", primary_key || false, primary_key_value)
raise ActiveRecord::InvalidForeignKey, "(reblog_of_id)=(#{values['reblog_of_id']}) is not present in table \"statuses\"" if result.nil?
result
end
def _compile_reblog_insert(values)
# This is somewhat equivalent to the following code of ActiveRecord::Persistence:
# `arel_table.compile_insert(_substitute_values(values))`
# The main difference is that we use a `SELECT` instead of a `VALUES` clause,
# which means we have to build the `SELECT` clause ourselves and do a bit more
# manual work.
# Instead of using Arel::InsertManager#values, we are going to use Arel::InsertManager#select
im = Arel::InsertManager.new
im.into(arel_table)
binds = []
reblog_bind = nil
values.each do |name, value|
attr = arel_table[name]
bind = predicate_builder.build_bind_attribute(attr.name, value)
im.columns << attr
binds << bind
reblog_bind = bind if name == 'reblog_of_id'
end
im.select(arel_table.where(arel_table[:id].eq(reblog_bind)).where(arel_table[:deleted_at].eq(nil)).project(*binds))
im
end
end
end

@ -32,7 +32,8 @@ class FollowRequest < ApplicationRecord
validates :languages, language: true
def authorize!
account.follow!(target_account, reblogs: show_reblogs, notify: notify, languages: languages, uri: uri, bypass_limit: true)
follow = account.follow!(target_account, reblogs: show_reblogs, notify: notify, languages: languages, uri: uri, bypass_limit: true)
ListAccount.where(follow_request: self).update_all(follow_request_id: nil, follow_id: follow.id) # rubocop:disable Rails/SkipsModelValidations
MergeWorker.perform_async(target_account.id, account.id) if account.local?
destroy!
end

@ -0,0 +1,151 @@
# frozen_string_literal: true
require 'csv'
# A non-ActiveRecord helper class for CSV uploads.
# Handles saving contents to database.
class Form::Import
include ActiveModel::Model
MODES = %i(merge overwrite).freeze
FILE_SIZE_LIMIT = 20.megabytes
ROWS_PROCESSING_LIMIT = 20_000
EXPECTED_HEADERS_BY_TYPE = {
following: ['Account address', 'Show boosts', 'Notify on new posts', 'Languages'],
blocking: ['Account address'],
muting: ['Account address', 'Hide notifications'],
domain_blocking: ['#domain'],
bookmarks: ['#uri'],
}.freeze
KNOWN_FIRST_HEADERS = EXPECTED_HEADERS_BY_TYPE.values.map(&:first).uniq.freeze
ATTRIBUTE_BY_HEADER = {
'Account address' => 'acct',
'Show boosts' => 'show_reblogs',
'Notify on new posts' => 'notify',
'Languages' => 'languages',
'Hide notifications' => 'hide_notifications',
'#domain' => 'domain',
'#uri' => 'uri',
}.freeze
class EmptyFileError < StandardError; end
attr_accessor :current_account, :data, :type, :overwrite, :bulk_import
validates :type, presence: true
validates :data, presence: true
validate :validate_data
def guessed_type
return :muting if csv_data.headers.include?('Hide notifications')
return :following if csv_data.headers.include?('Show boosts') || csv_data.headers.include?('Notify on new posts') || csv_data.headers.include?('Languages')
return :following if data.original_filename&.start_with?('follows') || data.original_filename&.start_with?('following_accounts')
return :blocking if data.original_filename&.start_with?('blocks') || data.original_filename&.start_with?('blocked_accounts')
return :muting if data.original_filename&.start_with?('mutes') || data.original_filename&.start_with?('muted_accounts')
return :domain_blocking if data.original_filename&.start_with?('domain_blocks') || data.original_filename&.start_with?('blocked_domains')
return :bookmarks if data.original_filename&.start_with?('bookmarks')
end
# Whether the uploaded CSV file seems to correspond to a different import type than the one selected
def likely_mismatched?
guessed_type.present? && guessed_type != type.to_sym
end
def save
return false unless valid?
ApplicationRecord.transaction do
now = Time.now.utc
@bulk_import = current_account.bulk_imports.create(type: type, overwrite: overwrite || false, state: :unconfirmed, original_filename: data.original_filename, likely_mismatched: likely_mismatched?)
nb_items = BulkImportRow.insert_all(parsed_rows.map { |row| { bulk_import_id: bulk_import.id, data: row, created_at: now, updated_at: now } }).length # rubocop:disable Rails/SkipsModelValidations
@bulk_import.update(total_items: nb_items)
end
end
def mode
overwrite ? :overwrite : :merge
end
def mode=(str)
self.overwrite = str.to_sym == :overwrite
end
private
def default_csv_header
case type.to_sym
when :following, :blocking, :muting
'Account address'
when :domain_blocking
'#domain'
when :bookmarks
'#uri'
end
end
def csv_data
return @csv_data if defined?(@csv_data)
csv_converter = lambda do |field, field_info|
case field_info.header
when 'Show boosts', 'Notify on new posts', 'Hide notifications'
ActiveModel::Type::Boolean.new.cast(field)
when 'Languages'
field&.split(',')&.map(&:strip)&.presence
when 'Account address'
field.strip.gsub(/\A@/, '')
when '#domain', '#uri'
field.strip
else
field
end
end
@csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: true, converters: csv_converter)
@csv_data.take(1) # Ensure the headers are read
raise EmptyFileError if @csv_data.headers == true
@csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: [default_csv_header], converters: csv_converter) unless KNOWN_FIRST_HEADERS.include?(@csv_data.headers&.first)
@csv_data
end
def csv_row_count
return @csv_row_count if defined?(@csv_row_count)
csv_data.rewind
@csv_row_count = csv_data.take(ROWS_PROCESSING_LIMIT + 2).count
end
def parsed_rows
csv_data.rewind
expected_headers = EXPECTED_HEADERS_BY_TYPE[type.to_sym]
csv_data.take(ROWS_PROCESSING_LIMIT + 1).map do |row|
row.to_h.slice(*expected_headers).transform_keys { |key| ATTRIBUTE_BY_HEADER[key] }
end
end
def validate_data
return if data.nil?
return errors.add(:data, I18n.t('imports.errors.too_large')) if data.size > FILE_SIZE_LIMIT
return errors.add(:data, I18n.t('imports.errors.incompatible_type')) unless csv_data.headers.include?(default_csv_header)
errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if csv_row_count > ROWS_PROCESSING_LIMIT
if type.to_sym == :following
base_limit = FollowLimitValidator.limit_for_account(current_account)
limit = base_limit
limit -= current_account.following_count unless overwrite
errors.add(:data, I18n.t('users.follow_limit_reached', limit: base_limit)) if csv_row_count > limit
end
rescue CSV::MalformedCSVError => e
errors.add(:data, I18n.t('imports.errors.invalid_csv_file', error: e.message))
rescue EmptyFileError
errors.add(:data, I18n.t('imports.errors.empty'))
end
end

@ -17,6 +17,9 @@
# overwrite :boolean default(FALSE), not null
#
# NOTE: This is a deprecated model, only kept to not break ongoing imports
# on upgrade. See `BulkImport` and `Form::Import` for its replacements.
class Import < ApplicationRecord
FILE_TYPES = %w(text/plain text/csv application/csv).freeze
MODES = %i(merge overwrite).freeze
@ -28,7 +31,6 @@ class Import < ApplicationRecord
enum type: { following: 0, blocking: 1, muting: 2, domain_blocking: 3, bookmarks: 4 }
validates :type, presence: true
validates_with ImportValidator, on: :create
has_attached_file :data
validates_attachment_content_type :data, content_type: FILE_TYPES

@ -4,24 +4,39 @@
#
# Table name: list_accounts
#
# id :bigint(8) not null, primary key
# list_id :bigint(8) not null
# account_id :bigint(8) not null
# follow_id :bigint(8)
# id :bigint(8) not null, primary key
# list_id :bigint(8) not null
# account_id :bigint(8) not null
# follow_id :bigint(8)
# follow_request_id :bigint(8)
#
class ListAccount < ApplicationRecord
belongs_to :list
belongs_to :account
belongs_to :follow, optional: true
belongs_to :follow_request, optional: true
validates :account_id, uniqueness: { scope: :list_id }
validate :validate_relationship
before_validation :set_follow
private
def set_follow
self.follow = Follow.find_by!(account_id: list.account_id, target_account_id: account.id) unless list.account_id == account.id
return if list.account_id == account.id
self.follow = Follow.find_by!(account_id: list.account_id, target_account_id: account.id)
rescue ActiveRecord::RecordNotFound
self.follow_request = FollowRequest.find_by!(account_id: list.account_id, target_account_id: account.id)
end
def validate_relationship
return if list.account_id == account_id
errors.add(:account_id, 'follow relationship missing') if follow_id.nil? && follow_request_id.nil?
errors.add(:follow, 'mismatched accounts') if follow_id.present? && follow.target_account_id != account_id
errors.add(:follow_request, 'mismatched accounts') if follow_request_id.present? && follow_request.target_account_id != account_id
end
end

@ -34,8 +34,8 @@ class MediaAttachment < ApplicationRecord
include Attachmentable
enum type: { :image => 0, :gifv => 1, :video => 2, :unknown => 3, :audio => 4 }
enum processing: { :queued => 0, :in_progress => 1, :complete => 2, :failed => 3 }, _prefix: true
enum type: { image: 0, gifv: 1, video: 2, unknown: 3, audio: 4 }
enum processing: { queued: 0, in_progress: 1, complete: 2, failed: 3 }, _prefix: true
MAX_DESCRIPTION_LENGTH = 1_500
@ -135,7 +135,7 @@ class MediaAttachment < ApplicationRecord
convert_options: {
output: {
'loglevel' => 'fatal',
vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
:vf => 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
}.freeze,
}.freeze,
format: 'png',
@ -169,6 +169,8 @@ class MediaAttachment < ApplicationRecord
original: IMAGE_STYLES[:small].freeze,
}.freeze
DEFAULT_STYLES = [:original].freeze
GLOBAL_CONVERT_OPTIONS = {
all: '-quality 90 +profile "!icc,*" +set modify-date +set create-date',
}.freeze
@ -271,12 +273,12 @@ class MediaAttachment < ApplicationRecord
delay_processing? && attachment_name == :file
end
after_commit :enqueue_processing, on: :create
after_commit :reset_parent_cache, on: :update
before_create :set_unknown_type
before_create :set_processing
after_commit :enqueue_processing, on: :create
after_commit :reset_parent_cache, on: :update
after_post_process :set_meta
class << self

@ -10,6 +10,8 @@ class RelationshipFilter
location
).freeze
IGNORED_PARAMS = %w(relationship page).freeze
attr_reader :params, :account
def initialize(account, params)
@ -23,7 +25,7 @@ class RelationshipFilter
scope = scope_for('relationship', params['relationship'].to_s.strip)
params.each do |key, value|
next if %w(relationship page).include?(key)
next if IGNORED_PARAMS.include?(key)
scope.merge!(scope_for(key.to_s, value.to_s.strip)) if value.present?
end

@ -36,8 +36,8 @@ class SessionActivation < ApplicationRecord
detection.platform.id
end
before_create :assign_access_token
before_save :assign_user_agent
before_create :assign_access_token
class << self
def active?(id)

@ -32,14 +32,13 @@
#
class Status < ApplicationRecord
before_destroy :unlink_from_conversations!
include Discard::Model
include Paginable
include Cacheable
include StatusThreadingConcern
include StatusSnapshotConcern
include RateLimitable
include StatusSafeReblogInsert
rate_limit by: :account, family: :statuses
@ -119,6 +118,28 @@ class Status < ApplicationRecord
after_create_commit :trigger_create_webhooks
after_update_commit :trigger_update_webhooks
after_create_commit :increment_counter_caches
after_destroy_commit :decrement_counter_caches
after_create_commit :store_uri, if: :local?
after_create_commit :update_statistics, if: :local?
before_validation :prepare_contents, if: :local?
before_validation :set_reblog
before_validation :set_visibility
before_validation :set_conversation
before_validation :set_local
before_create :set_local_only
around_create Mastodon::Snowflake::Callbacks
after_create :set_poll_id
# The `prepend: true` option below ensures this runs before
# the `dependent: destroy` callbacks remove relevant records
before_destroy :unlink_from_conversations!, prepend: true
cache_associated :application,
:media_attachments,
:conversation,
@ -316,23 +337,6 @@ class Status < ApplicationRecord
attributes['trendable'].nil? && account.requires_review_notification?
end
after_create_commit :increment_counter_caches
after_destroy_commit :decrement_counter_caches
after_create_commit :store_uri, if: :local?
after_create_commit :update_statistics, if: :local?
before_validation :prepare_contents, if: :local?
before_validation :set_reblog
before_validation :set_visibility
before_validation :set_conversation
before_validation :set_local
before_create :set_locality
around_create Mastodon::Snowflake::Callbacks
after_create :set_poll_id
class << self
def selectable_visibilities
visibilities.keys - %w(direct limited)
@ -442,71 +446,6 @@ class Status < ApplicationRecord
super || build_status_stat
end
# This is a hack to ensure that no reblogs of discarded statuses are created,
# as this cannot be enforced through database constraints the same way we do
# for reblogs of deleted statuses.
#
# To achieve this, we redefine the internal method responsible for issuing
# the "INSERT" statement and replace the "INSERT INTO ... VALUES ..." query
# with an "INSERT INTO ... SELECT ..." query with a "WHERE deleted_at IS NULL"
# clause on the reblogged status to ensure consistency at the database level.
#
# Otherwise, the code is kept as close as possible to ActiveRecord::Persistence
# code, and actually calls it if we are not handling a reblog.
def self._insert_record(values)
return super unless values.is_a?(Hash) && values['reblog_of_id'].present?
primary_key = self.primary_key
primary_key_value = nil
if primary_key
primary_key_value = values[primary_key]
if !primary_key_value && prefetch_primary_key?
primary_key_value = next_sequence_value
values[primary_key] = primary_key_value
end
end
# The following line is where we differ from stock ActiveRecord implementation
im = _compile_reblog_insert(values)
# Since we are using SELECT instead of VALUES, a non-error `nil` return is possible.
# For our purposes, it's equivalent to a foreign key constraint violation
result = connection.insert(im, "#{self} Create", primary_key || false, primary_key_value)
raise ActiveRecord::InvalidForeignKey, "(reblog_of_id)=(#{values['reblog_of_id']}) is not present in table \"statuses\"" if result.nil?
result
end
def self._compile_reblog_insert(values)
# This is somewhat equivalent to the following code of ActiveRecord::Persistence:
# `arel_table.compile_insert(_substitute_values(values))`
# The main difference is that we use a `SELECT` instead of a `VALUES` clause,
# which means we have to build the `SELECT` clause ourselves and do a bit more
# manual work.
# Instead of using Arel::InsertManager#values, we are going to use Arel::InsertManager#select
im = Arel::InsertManager.new
im.into(arel_table)
binds = []
reblog_bind = nil
values.each do |name, value|
attr = arel_table[name]
bind = predicate_builder.build_bind_attribute(attr.name, value)
im.columns << attr
binds << bind
reblog_bind = bind if name == 'reblog_of_id'
end
im.select(arel_table.where(arel_table[:id].eq(reblog_bind)).where(arel_table[:deleted_at].eq(nil)).project(*binds))
im
end
def discard_with_reblogs
discard_time = Time.current
Status.unscoped.where(reblog_of_id: id, deleted_at: [nil, deleted_at]).in_batches.update_all(deleted_at: discard_time) unless reblog?
@ -555,7 +494,7 @@ class Status < ApplicationRecord
self.sensitive = false if sensitive.nil?
end
def set_locality
def set_local_only
return unless account.domain.nil? && !attribute_changed?(:local_only)
self.local_only = marked_local_only?

@ -11,7 +11,7 @@ class Trends::History
end
def uses
with_redis { |redis| redis.mget(*@days.map { |day| day.key_for(:uses) }).map(&:to_i).sum }
with_redis { |redis| redis.mget(*@days.map { |day| day.key_for(:uses) }).sum(&:to_i) }
end
def accounts

@ -6,6 +6,8 @@ class Trends::PreviewCardFilter
locale
).freeze
IGNORED_PARAMS = %w(page).freeze
attr_reader :params
def initialize(params)
@ -16,7 +18,7 @@ class Trends::PreviewCardFilter
scope = initial_scope
params.each do |key, value|
next if %w(page).include?(key.to_s)
next if IGNORED_PARAMS.include?(key.to_s)
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
end

@ -6,6 +6,8 @@ class Trends::StatusFilter
locale
).freeze
IGNORED_PARAMS = %w(page).freeze
attr_reader :params
def initialize(params)
@ -16,7 +18,7 @@ class Trends::StatusFilter
scope = initial_scope
params.each do |key, value|
next if %w(page).include?(key.to_s)
next if IGNORED_PARAMS.include?(key.to_s)
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
end

@ -1,6 +1,8 @@
# frozen_string_literal: true
class StatusRelationshipsPresenter
PINNABLE_VISIBILITIES = %w(public unlisted private).freeze
attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map,
:bookmarks_map, :filters_map
@ -16,7 +18,7 @@ class StatusRelationshipsPresenter
statuses = statuses.compact
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
conversation_ids = statuses.filter_map(&:conversation_id).uniq
pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && %w(public unlisted private).include?(s.visibility) }
pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && PINNABLE_VISIBILITIES.include?(s.visibility) }
@filters_map = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {})
@reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})

@ -67,7 +67,7 @@ class ActivityPub::FetchRemoteActorService < BaseService
end
def split_acct(acct)
acct.gsub(/\Aacct:/, '').split('@')
acct.delete_prefix('acct:').split('@')
end
def supported_context?

@ -2,12 +2,15 @@
class ActivityPub::FetchRemoteStatusService < BaseService
include JsonLdHelper
include DomainControlHelper
include Redisable
DISCOVERIES_PER_REQUEST = 1000
# Should be called when uri has already been checked for locality
def call(uri, id: true, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
return if domain_not_allowed?(uri)
@request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}"
@json = if prefetched_body.nil?
fetch_resource(uri, id, on_behalf_of)

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

Loading…
Cancel
Save