Merge pull request #2215 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes
This commit is contained in:
		
						commit
						f08f6d20e6
					
				|  | @ -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: | ||||
|  |  | |||
							
								
								
									
										2
									
								
								.profile
								
								
								
								
							
							
						
						
									
										2
									
								
								.profile
								
								
								
								
							|  | @ -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,30 @@ 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/20171210213213_add_local_only_flag_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 +1045,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 +1062,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 +1515,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 +1549,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 +1556,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 +1573,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 +1595,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 +1682,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 +1803,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. | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
							
								
								
									
										6
									
								
								Gemfile
								
								
								
								
							
							
						
						
									
										6
									
								
								Gemfile
								
								
								
								
							|  | @ -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' | ||||
|  |  | |||
							
								
								
									
										33
									
								
								Gemfile.lock
								
								
								
								
							
							
						
						
									
										33
									
								
								Gemfile.lock
								
								
								
								
							|  | @ -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 | ||||
| 
 | ||||
| class Settings::ImportsController < Settings::BaseController | ||||
|   before_action :set_account | ||||
| require 'csv' | ||||
| 
 | ||||
|   def show | ||||
|     @import = Import.new | ||||
| class Settings::ImportsController < Settings::BaseController | ||||
|   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 | ||||
| 
 | ||||
|         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,6 +0,0 @@ | |||
| export const APP_LAYOUT_CHANGE = 'APP_LAYOUT_CHANGE'; | ||||
| 
 | ||||
| export const changeLayout = layout => ({ | ||||
|   type: APP_LAYOUT_CHANGE, | ||||
|   layout, | ||||
| }); | ||||
|  | @ -0,0 +1,7 @@ | |||
| import { createAction } from '@reduxjs/toolkit'; | ||||
| 
 | ||||
| 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; | ||||
|   } | ||||
|  | @ -2,7 +2,7 @@ import * as React from 'react'; | |||
| import classNames from 'classnames'; | ||||
| import { autoPlayGif } from 'flavours/glitch/initial_state'; | ||||
| import { useHovering } from 'hooks/useHovering'; | ||||
| import type { Account } from 'types/resources'; | ||||
| import type { Account } from 'flavours/glitch/types/resources'; | ||||
| 
 | ||||
| type Props = { | ||||
|   account: Account | undefined; | ||||
|  |  | |||
|  | @ -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); | ||||
|  | @ -1,35 +1,37 @@ | |||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import classNames from 'classnames'; | ||||
| import Icon from 'flavours/glitch/components/icon'; | ||||
| import AnimatedNumber from 'flavours/glitch/components/animated_number'; | ||||
| import { Icon } from './icon'; | ||||
| import { AnimatedNumber } from './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, | ||||
|     label: PropTypes.string, | ||||
|     counter: PropTypes.number, | ||||
|     obfuscateCount: PropTypes.bool, | ||||
|     href: PropTypes.string, | ||||
|     ariaHidden: PropTypes.bool, | ||||
|   }; | ||||
| 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; | ||||
|   label: string; | ||||
|   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, | ||||
|  | @ -46,7 +48,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) { | ||||
|  | @ -56,27 +58,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); | ||||
|     } | ||||
|  | @ -89,7 +91,7 @@ export default class IconButton extends React.PureComponent { | |||
|       containerSize = `${this.props.size * 1.28571429}px`; | ||||
|     } | ||||
| 
 | ||||
|     let style = { | ||||
|     const style = { | ||||
|       fontSize: `${this.props.size}px`, | ||||
|       height: containerSize, | ||||
|       lineHeight: `${this.props.size}px`, | ||||
|  | @ -144,7 +146,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} | ||||
|  | @ -101,12 +101,10 @@ class Item extends React.PureComponent { | |||
|   render () { | ||||
|     const { attachment, lang, index, size, standalone, letterbox, 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; | ||||
|  | @ -116,45 +114,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')} | ||||
|  | @ -205,6 +171,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 | ||||
|  | @ -222,14 +190,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, letterbox })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> | ||||
|       <div className={classNames('media-gallery__item', { standalone, letterbox, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}> | ||||
|         <Blurhash | ||||
|           hash={attachment.get('blurhash')} | ||||
|           dummy={!useBlurhash} | ||||
|  | @ -237,7 +203,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> | ||||
|     ); | ||||
|   } | ||||
|  | @ -358,12 +331,10 @@ class MediaGallery extends React.PureComponent { | |||
| 
 | ||||
|     const computedClass = classNames('media-gallery', { 'full-width': fullwidth }); | ||||
| 
 | ||||
|     if (this.isStandaloneEligible() && width) { | ||||
|       style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); | ||||
|     } else if (width) { | ||||
|       style.height = width / (16/9); | ||||
|     if (this.isStandaloneEligible()) { // TODO: cropImages setting | ||||
|       style.aspectRatio = `${this.props.media.getIn([0, 'meta', 'small', 'aspect'])}`; | ||||
|     } else { | ||||
|       return (<div className={computedClass} ref={this.handleRef} />); | ||||
|       style.aspectRatio = '16 / 9'; | ||||
|     } | ||||
| 
 | ||||
|     if (this.isStandaloneEligible()) { | ||||
|  |  | |||
|  | @ -3,62 +3,22 @@ import PropTypes from 'prop-types'; | |||
| import Icon from 'flavours/glitch/components/icon'; | ||||
| import { removePictureInPicture } from 'flavours/glitch/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> | ||||
|  |  | |||
|  | @ -624,7 +624,7 @@ class Status extends ImmutablePureComponent { | |||
|     attachments = status.get('media_attachments'); | ||||
| 
 | ||||
|     if (pictureInPicture.get('inUse')) { | ||||
|       media.push(<PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />); | ||||
|       media.push(<PictureInPicturePlaceholder />); | ||||
|       mediaIcons.push('video-camera'); | ||||
|     } else if (attachments.size > 0) { | ||||
|       if (muted || attachments.some(item => item.get('type') === 'unknown')) { | ||||
|  | @ -680,8 +680,6 @@ class Status extends ImmutablePureComponent { | |||
|               fullwidth={!rootId && settings.getIn(['media', 'fullwidth'])} | ||||
|               preventPlayback={isCollapsed || !isExpanded} | ||||
|               onOpenVideo={this.handleOpenVideo} | ||||
|               width={this.props.cachedMediaWidth} | ||||
|               cacheWidth={this.props.cacheMediaWidth} | ||||
|               deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined} | ||||
|               visible={this.state.showMedia} | ||||
|               onToggleVisibility={this.handleToggleMediaVisibility} | ||||
|  | @ -721,8 +719,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 'flavours/glitch/store/configureStore'; | ||||
| import { store } from 'flavours/glitch/store/configureStore'; | ||||
| import { hydrateStore } from 'flavours/glitch/actions/store'; | ||||
| import { IntlProvider, addLocaleData } from 'react-intl'; | ||||
| import { getLocale } from 'mastodon/locales'; | ||||
|  | @ -12,8 +12,6 @@ import { fetchCustomEmojis } from 'flavours/glitch/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 'flavours/glitch/store/configureStore'; | ||||
| import { store } from 'flavours/glitch/store/configureStore'; | ||||
| import UI from 'flavours/glitch/features/ui'; | ||||
| import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis'; | ||||
| import { hydrateStore } from 'flavours/glitch/actions/store'; | ||||
|  | @ -20,7 +20,6 @@ addLocaleData(localeData); | |||
| 
 | ||||
| const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`; | ||||
| 
 | ||||
| export const store = configureStore(); | ||||
| const hydrateAction = hydrateStore(initialState); | ||||
| store.dispatch(hydrateAction); | ||||
| 
 | ||||
|  |  | |||
|  | @ -390,7 +390,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 () { | ||||
|  | @ -402,7 +402,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 () { | ||||
|  | @ -476,7 +476,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} | ||||
|  | @ -521,9 +521,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 { decode as decodeIDNA } from 'flavours/glitch/utils/idna'; | |||
| import Icon from 'flavours/glitch/components/icon'; | ||||
| import { useBlurhash } from 'flavours/glitch/initial_state'; | ||||
| import Blurhash from 'flavours/glitch/components/blurhash'; | ||||
| import { debounce } from 'lodash'; | ||||
| 
 | ||||
| const getHostname = url => { | ||||
|   const parser = document.createElement('a'); | ||||
|  | @ -45,8 +44,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, | ||||
|   }; | ||||
| 
 | ||||
|  | @ -55,7 +52,6 @@ export default class Card extends React.PureComponent { | |||
|   }; | ||||
| 
 | ||||
|   state = { | ||||
|     width: this.props.defaultWidth || 280, | ||||
|     previewLoaded: false, | ||||
|     embedded: false, | ||||
|     revealed: !this.props.sensitive, | ||||
|  | @ -78,24 +74,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; | ||||
| 
 | ||||
|  | @ -129,10 +107,6 @@ export default class Card extends React.PureComponent { | |||
| 
 | ||||
|   setRef = c => { | ||||
|     this.node = c; | ||||
| 
 | ||||
|     if (this.node) { | ||||
|       this._setDimensions(); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   handleImageLoad = () => { | ||||
|  | @ -148,36 +122,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}> | ||||
|  | @ -187,6 +156,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 | ||||
|  | @ -197,7 +174,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> | ||||
|  |  | |||
|  | @ -378,7 +378,7 @@ class UI extends React.Component { | |||
| 
 | ||||
|     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 'flavours/glitch/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, | ||||
|     letterbox: PropTypes.bool, | ||||
|     fullwidth: PropTypes.bool, | ||||
|  | @ -138,41 +135,16 @@ class Video extends React.PureComponent { | |||
|     volume: 0.5, | ||||
|     paused: true, | ||||
|     dragging: false, | ||||
|     containerWidth: this.props.width, | ||||
|     fullscreen: false, | ||||
|     hovered: false, | ||||
|     muted: false, | ||||
|     revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'), | ||||
|   }; | ||||
| 
 | ||||
|   componentWillReceiveProps (nextProps) { | ||||
|     if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) { | ||||
|       this.setState({ revealed: nextProps.visible }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   setPlayerRef = c => { | ||||
|     this.player = c; | ||||
| 
 | ||||
|     if (this.player) { | ||||
|       this._setDimensions(); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   _setDimensions () { | ||||
|     const width = this.player.offsetWidth; | ||||
| 
 | ||||
|     if (width && width !== this.state.containerWidth) { | ||||
|       if (this.props.cacheWidth) { | ||||
|         this.props.cacheWidth(width); | ||||
|       } | ||||
| 
 | ||||
|       this.setState({ | ||||
|         containerWidth: width, | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   setVideoRef = c => { | ||||
|     this.video = c; | ||||
| 
 | ||||
|  | @ -381,12 +353,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); | ||||
|  | @ -403,26 +373,18 @@ class Video extends React.PureComponent { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   componentDidUpdate (prevProps) { | ||||
|     if (this.player && this.player.offsetWidth && this.player.offsetWidth !== this.state.containerWidth && !this.state.fullscreen) { | ||||
|       if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth); | ||||
|       this.setState({ | ||||
|         containerWidth: this.player.offsetWidth, | ||||
|       }); | ||||
|   componentWillReceiveProps (nextProps) { | ||||
|     if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) { | ||||
|       this.setState({ revealed: nextProps.visible }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   componentDidUpdate (prevProps) { | ||||
|     if (this.video && this.state.revealed && this.props.preventPlayback && !prevProps.preventPlayback) { | ||||
|       this.video.pause(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   handleResize = debounce(() => { | ||||
|     if (this.player) { | ||||
|       this._setDimensions(); | ||||
|     } | ||||
|   }, 250, { | ||||
|     trailing: true, | ||||
|   }); | ||||
| 
 | ||||
|   handleScroll = throttle(() => { | ||||
|     if (!this.video) { | ||||
|       return; | ||||
|  | @ -540,21 +502,12 @@ class Video extends React.PureComponent { | |||
| 
 | ||||
|   render () { | ||||
|     const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, lang, letterbox, fullwidth, 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 = {}; | ||||
| 
 | ||||
|     const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth }); | ||||
| 
 | ||||
|     let { width, height } = this.props; | ||||
| 
 | ||||
|     if (inline && containerWidth) { | ||||
|       width  = containerWidth; | ||||
|       height = containerWidth / (16/9); | ||||
| 
 | ||||
|       playerStyle.height = height; | ||||
|     } else if (inline) { | ||||
|       return (<div className={computedClass} ref={this.setPlayerRef} tabIndex={0} />); | ||||
|     if (inline) { | ||||
|       playerStyle.aspectRatio = '16 / 9'; | ||||
|     } | ||||
| 
 | ||||
|     let preload; | ||||
|  | @ -578,7 +531,7 @@ class Video extends React.PureComponent { | |||
|     return ( | ||||
|       <div | ||||
|         role='menuitem' | ||||
|         className={computedClass} | ||||
|         className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth })} | ||||
|         style={playerStyle} | ||||
|         ref={this.setPlayerRef} | ||||
|         onMouseEnter={this.handleMouseEnter} | ||||
|  | @ -605,8 +558,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} | ||||
|  | @ -615,6 +566,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'; | ||||
| import { forceSingleColumn } from 'flavours/glitch/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; | ||||
| 
 | ||||
| /** | ||||
|  * @param {string} layout_local_setting | ||||
|  * @returns {string} | ||||
|  */ | ||||
| export const layoutFromWindow = (layout_local_setting) => { | ||||
| export type LayoutType = 'mobile' | 'single-column' | 'multi-column'; | ||||
| export const layoutFromWindow = (layout_local_setting : string): LayoutType => { | ||||
|   switch (layout_local_setting) { | ||||
|   case 'multiple': | ||||
|     return 'multi-column'; | ||||
|  | @ -36,8 +27,9 @@ export const layoutFromWindow = (layout_local_setting) => { | |||
|   } | ||||
| }; | ||||
| 
 | ||||
| // 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 'flavours/glitch/actions/notifications'; | ||||
| import Mastodon, { store } from 'flavours/glitch/containers/mastodon'; | ||||
| import Mastodon from 'flavours/glitch/containers/mastodon'; | ||||
| import { store } from 'flavours/glitch/store/configureStore'; | ||||
| import { me } from 'flavours/glitch/initial_state'; | ||||
| import ready from 'flavours/glitch/ready'; | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; | ||||
| import { APP_LAYOUT_CHANGE } from 'flavours/glitch/actions/app'; | ||||
| import { changeLayout } from 'flavours/glitch/actions/app'; | ||||
| import { Map as ImmutableMap } from 'immutable'; | ||||
| import { layoutFromWindow } from 'flavours/glitch/is_mobile'; | ||||
| 
 | ||||
|  | @ -16,8 +16,8 @@ export default function meta(state = initialState, action) { | |||
|     return state.merge(action.state.get('meta')) | ||||
|       .set('permissions', action.state.getIn(['role', 'permissions'])) | ||||
|       .set('layout', layoutFromWindow(action.state.getIn(['local_settings', 'layout']))); | ||||
|   case APP_LAYOUT_CHANGE: | ||||
|     return state.set('layout', action.layout); | ||||
|   case changeLayout.type: | ||||
|     return state.set('layout', action.payload.layout); | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
|  |  | |||
|  | @ -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)); | ||||
| } | ||||
|   ], | ||||
| }); | ||||
|  |  | |||
|  | @ -47,7 +47,6 @@ | |||
|     margin-right: -14px; | ||||
|     width: inherit; | ||||
|     max-width: none; | ||||
|     height: 250px; | ||||
|     border-radius: 0; | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -43,30 +43,25 @@ | |||
|   font-weight: 500; | ||||
| } | ||||
| 
 | ||||
| .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; | ||||
|   padding: 2px 6px; | ||||
|   border-radius: 2px; | ||||
|   font-size: 11px; | ||||
|   font-weight: 600; | ||||
|   z-index: 1; | ||||
|   pointer-events: none; | ||||
|   opacity: 0.9; | ||||
|   transition: opacity 0.1s ease; | ||||
|   line-height: 18px; | ||||
|   display: flex; | ||||
|   gap: 2px; | ||||
| } | ||||
| 
 | ||||
| .media-gallery__gifv { | ||||
|   &:hover { | ||||
|     .media-gallery__gifv__label { | ||||
|       opacity: 1; | ||||
|     } | ||||
|   } | ||||
| .media-gallery__gifv__label { | ||||
|   display: block; | ||||
|   color: $white; | ||||
|   background: rgba($black, 0.65); | ||||
|   padding: 2px 6px; | ||||
|   border-radius: 4px; | ||||
|   font-size: 11px; | ||||
|   font-weight: 700; | ||||
|   z-index: 1; | ||||
|   pointer-events: none; | ||||
|   line-height: 18px; | ||||
| } | ||||
| 
 | ||||
| .media-gallery { | ||||
|  | @ -77,6 +72,10 @@ | |||
|   position: relative; | ||||
|   width: 100%; | ||||
|   min-height: 64px; | ||||
|   display: grid; | ||||
|   grid-template-columns: 50% 50%; | ||||
|   grid-template-rows: 50% 50%; | ||||
|   gap: 2px; | ||||
| 
 | ||||
|   @include fullwidth-gallery; | ||||
| } | ||||
|  | @ -85,13 +84,16 @@ | |||
|   border: 0; | ||||
|   box-sizing: border-box; | ||||
|   display: block; | ||||
|   float: left; | ||||
|   position: relative; | ||||
|   border-radius: 4px; | ||||
|   overflow: hidden; | ||||
| 
 | ||||
|   .full-width & { | ||||
|     border-radius: 0; | ||||
|   &--tall { | ||||
|     grid-row: span 2; | ||||
|   } | ||||
| 
 | ||||
|   &--wide { | ||||
|     grid-column: span 2; | ||||
|   } | ||||
| 
 | ||||
|   &.standalone { | ||||
|  | @ -101,6 +103,10 @@ | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .full-width & { | ||||
|     border-radius: 0; | ||||
|   } | ||||
| 
 | ||||
|   &.letterbox { | ||||
|     background: $base-shadow-color; | ||||
|   } | ||||
|  |  | |||
|  | @ -691,7 +691,6 @@ a.status__display-name, | |||
|   margin-inline-end: 10px; | ||||
|   height: 48px; | ||||
|   width: 48px; | ||||
|   box-shadow: 0 0 0 2px $ui-base-color; | ||||
| } | ||||
| 
 | ||||
| .muted { | ||||
|  | @ -809,6 +808,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%; | ||||
|  | @ -1154,6 +1157,7 @@ a.status-card.compact:hover { | |||
|   font-weight: 500; | ||||
|   cursor: pointer; | ||||
|   color: $darker-text-color; | ||||
|   aspect-ratio: 16 / 9; | ||||
| 
 | ||||
|   i { | ||||
|     display: block; | ||||
|  |  | |||
|  | @ -0,0 +1,10 @@ | |||
| import type { Record } from 'immutable'; | ||||
| 
 | ||||
| type AccountValues = { | ||||
|   id: number; | ||||
|   avatar: string; | ||||
|   avatar_static: string; | ||||
|   [key: string]: any; | ||||
| }; | ||||
| 
 | ||||
| export type Account = Record<AccountValues>; | ||||
|  | @ -0,0 +1 @@ | |||
| export type ValueOf<T> = T[keyof T]; | ||||
|  | @ -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 'flavours/glitch/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; | ||||
| } | ||||
|  | @ -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'; | ||||
| import { Icon } from './icon'; | ||||
| import { AnimatedNumber } from './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, | ||||
|   }; | ||||
| 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,30 +6333,25 @@ 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; | ||||
|   padding: 2px 6px; | ||||
|   border-radius: 2px; | ||||
|   font-size: 11px; | ||||
|   font-weight: 600; | ||||
|   z-index: 1; | ||||
|   pointer-events: none; | ||||
|   opacity: 0.9; | ||||
|   transition: opacity 0.1s ease; | ||||
|   line-height: 18px; | ||||
|   display: flex; | ||||
|   gap: 2px; | ||||
| } | ||||
| 
 | ||||
| .media-gallery__gifv { | ||||
|   &:hover { | ||||
|     .media-gallery__gifv__label { | ||||
|       opacity: 1; | ||||
|     } | ||||
|   } | ||||
| .media-gallery__gifv__label { | ||||
|   display: block; | ||||
|   color: $white; | ||||
|   background: rgba($black, 0.65); | ||||
|   padding: 2px 6px; | ||||
|   border-radius: 4px; | ||||
|   font-size: 11px; | ||||
|   font-weight: 700; | ||||
|   z-index: 1; | ||||
|   pointer-events: none; | ||||
|   line-height: 18px; | ||||
| } | ||||
| 
 | ||||
| .attachment-list { | ||||
|  | @ -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 | ||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
		Reference in New Issue