diff --git a/.eslintrc.js b/.eslintrc.js index bbdfa7de2..259c86157 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,6 +27,7 @@ module.exports = { 'import', 'promise', '@typescript-eslint', + 'formatjs', ], parserOptions: { @@ -71,7 +72,7 @@ module.exports = { 'comma-style': ['warn', 'last'], 'consistent-return': 'error', 'dot-notation': 'error', - eqeqeq: 'error', + eqeqeq: ['error', 'always', { 'null': 'ignore' }], indent: ['warn', 2], 'jsx-quotes': ['error', 'prefer-single'], 'no-case-declarations': 'off', @@ -218,6 +219,25 @@ module.exports = { 'promise/no-callback-in-promise': 'off', 'promise/no-nesting': 'off', 'promise/no-promise-in-callback': 'off', + + 'formatjs/blocklist-elements': 'error', + 'formatjs/enforce-default-message': ['error', 'literal'], + 'formatjs/enforce-description': 'off', // description values not currently used + 'formatjs/enforce-id': 'off', // Explicit IDs are used in the project + 'formatjs/enforce-placeholders': 'off', // Issues in short_number.jsx + 'formatjs/enforce-plural-rules': 'error', + 'formatjs/no-camel-case': 'off', // disabledAccount is only non-conforming + 'formatjs/no-complex-selectors': 'error', + 'formatjs/no-emoji': 'error', + 'formatjs/no-id': 'off', // IDs are used for translation keys + 'formatjs/no-invalid-icu': 'error', + 'formatjs/no-literal-string-in-jsx': 'off', // Should be looked at, but mainly flagging punctuation outside of strings + 'formatjs/no-multiple-plurals': 'off', // Only used by hashtag.jsx + 'formatjs/no-multiple-whitespaces': 'error', + 'formatjs/no-offset': 'error', + 'formatjs/no-useless-message': 'error', + 'formatjs/prefer-formatted-message': 'error', + 'formatjs/prefer-pound-in-plural': 'error', }, overrides: [ diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-nightly.yml new file mode 100644 index 000000000..501db6e9c --- /dev/null +++ b/.github/workflows/build-nightly.yml @@ -0,0 +1,54 @@ +name: Build nightly container image +on: + workflow_dispatch: + schedule: + - cron: '0 2 * * *' # run at 2 AM UTC +permissions: + contents: read + packages: write + +jobs: + build-nightly-image: + runs-on: ubuntu-latest + + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@v3 + - uses: hadolint/hadolint-action@v3.1.0 + - uses: docker/setup-qemu-action@v2 + - uses: docker/setup-buildx-action@v2 + + - name: Log in to the Github Container registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/metadata-action@v4 + id: meta + with: + images: | + ghcr.io/mastodon/mastodon + flavor: | + latest=auto + tags: | + type=raw,value=nightly + type=schedule,pattern=nightly-{{date 'YYYY-MM-DD' tz='Etc/UTC'}} + labels: | + org.opencontainers.image.description=Nightly build image used for testing purposes + + - uses: docker/build-push-action@v4 + with: + context: . + platforms: linux/amd64,linux/arm64 + provenance: false + builder: ${{ steps.buildx.outputs.name }} + push: ${{ github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index d1aa8468a..6b8d6fdfc 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -104,7 +104,6 @@ jobs: fail-fast: false matrix: ruby-version: - - '2.7' - '3.0' - '3.1' - '.ruby-version' @@ -136,10 +135,6 @@ jobs: ruby-version: ${{ matrix.ruby-version}} bundler-cache: true - - name: Update system gems - if: matrix.ruby-version == '2.7' - run: gem update --system - - name: Load database schema run: './bin/rails db:create db:schema:load db:seed' diff --git a/.rubocop.yml b/.rubocop.yml index e6a0c2d14..b510c4303 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,7 +13,7 @@ require: - rubocop-capybara AllCops: - TargetRubyVersion: 2.7 # Set to minimum supported version of CI + TargetRubyVersion: 3.0 # Set to minimum supported version of CI DisplayCopNames: true DisplayStyleGuide: true ExtraDetails: true diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index dc7e21dc5..26c89ca78 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp` -# using RuboCop version 1.48.1. +# using RuboCop version 1.50.2. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -132,7 +132,6 @@ Lint/DuplicateBranch: Lint/EmptyBlock: Exclude: - 'spec/controllers/api/v2/search_controller_spec.rb' - - 'spec/controllers/application_controller_spec.rb' - 'spec/fabricators/access_token_fabricator.rb' - 'spec/fabricators/conversation_fabricator.rb' - 'spec/fabricators/system_key_fabricator.rb' @@ -174,11 +173,6 @@ Lint/EmptyClass: Exclude: - 'spec/controllers/api/base_controller_spec.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -Lint/NonDeterministicRequireOrder: - Exclude: - - 'spec/rails_helper.rb' - Lint/NonLocalExitFromIterator: Exclude: - 'app/helpers/jsonld_helper.rb' @@ -251,7 +245,6 @@ Metrics/ModuleLength: - 'app/controllers/concerns/signature_verification.rb' - 'app/helpers/application_helper.rb' - 'app/helpers/jsonld_helper.rb' - - 'app/helpers/statuses_helper.rb' - 'app/models/concerns/account_interactions.rb' - 'app/models/concerns/has_user_settings.rb' @@ -370,6 +363,7 @@ Performance/MethodObjectAsBlock: - 'spec/models/export_spec.rb' # This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: AllowRegexpMatch. Performance/RedundantEqualityComparisonBlock: Exclude: - 'spec/requests/link_headers_spec.rb' @@ -699,7 +693,6 @@ RSpec/HookArgument: RSpec/InstanceVariable: Exclude: - 'spec/controllers/api/v1/streaming_controller_spec.rb' - - 'spec/controllers/application_controller_spec.rb' - 'spec/controllers/auth/confirmations_controller_spec.rb' - 'spec/controllers/auth/passwords_controller_spec.rb' - 'spec/controllers/auth/sessions_controller_spec.rb' @@ -753,7 +746,6 @@ 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/tags_controller_spec.rb' - 'spec/lib/activitypub/activity/delete_spec.rb' - 'spec/lib/vacuum/preview_cards_vacuum_spec.rb' - 'spec/models/account_spec.rb' @@ -780,29 +772,6 @@ RSpec/LetSetup: - 'spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb' - 'spec/workers/scheduler/user_cleanup_scheduler_spec.rb' -# This cop supports safe autocorrection (--autocorrect). -RSpec/MatchArray: - Exclude: - - 'spec/controllers/activitypub/followers_synchronizations_controller_spec.rb' - - 'spec/controllers/admin/export_domain_blocks_controller_spec.rb' - - 'spec/controllers/api/v1/accounts/follower_accounts_controller_spec.rb' - - 'spec/controllers/api/v1/accounts/following_accounts_controller_spec.rb' - - 'spec/controllers/api/v1/accounts/statuses_controller_spec.rb' - - 'spec/controllers/api/v1/bookmarks_controller_spec.rb' - - 'spec/controllers/api/v1/favourites_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/models/account_filter_spec.rb' - - 'spec/models/account_spec.rb' - - 'spec/models/account_statuses_cleanup_policy_spec.rb' - - 'spec/models/custom_emoji_filter_spec.rb' - - 'spec/models/status_spec.rb' - - 'spec/models/user_spec.rb' - - 'spec/presenters/familiar_followers_presenter_spec.rb' - - 'spec/services/activitypub/fetch_featured_collection_service_spec.rb' - - 'spec/services/update_status_service_spec.rb' - RSpec/MessageChain: Exclude: - 'spec/controllers/api/v1/media_controller_spec.rb' @@ -842,7 +811,6 @@ RSpec/MissingExampleGroupArgument: - 'spec/controllers/api/v1/admin/account_actions_controller_spec.rb' - 'spec/controllers/api/v1/admin/domain_allows_controller_spec.rb' - 'spec/controllers/api/v1/statuses_controller_spec.rb' - - 'spec/controllers/application_controller_spec.rb' - 'spec/controllers/auth/registrations_controller_spec.rb' - 'spec/features/log_in_spec.rb' - 'spec/lib/activitypub/activity/undo_spec.rb' @@ -1225,9 +1193,6 @@ Rails/ActiveRecordCallbacksOrder: Rails/ApplicationController: Exclude: - 'app/controllers/health_controller.rb' - - 'app/controllers/well_known/host_meta_controller.rb' - - 'app/controllers/well_known/nodeinfo_controller.rb' - - 'app/controllers/well_known/webfinger_controller.rb' # Configuration parameters: Database, Include. # SupportedDatabases: mysql, postgresql @@ -1405,14 +1370,6 @@ Rails/HasManyOrHasOneDependent: - 'app/models/user.rb' - 'app/models/web/push_subscription.rb' -# Configuration parameters: Include. -# Include: app/helpers/**/*.rb -Rails/HelperInstanceVariable: - Exclude: - - 'app/helpers/application_helper.rb' - - 'app/helpers/instance_helper.rb' - - 'app/helpers/jsonld_helper.rb' - # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: Include. # Include: spec/**/*, test/**/* @@ -1502,15 +1459,6 @@ Rails/RakeEnvironment: - 'lib/tasks/repo.rake' - 'lib/tasks/statistics.rake' -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: Include. -# Include: spec/controllers/**/*.rb, spec/requests/**/*.rb, test/controllers/**/*.rb, test/integration/**/*.rb -Rails/ResponseParsedBody: - Exclude: - - 'spec/controllers/follower_accounts_controller_spec.rb' - - 'spec/controllers/following_accounts_controller_spec.rb' - - 'spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb' - # Configuration parameters: Include. # Include: db/**/*.rb Rails/ReversibleMigration: @@ -2256,16 +2204,11 @@ Style/MapToHash: # SupportedStyles: literals, strict Style/MutableConstant: Exclude: - - 'app/lib/link_details_extractor.rb' - 'app/models/account.rb' - - 'app/models/custom_emoji.rb' - 'app/models/tag.rb' - - 'app/services/account_search_service.rb' - 'app/services/delete_account_service.rb' - - 'app/services/fetch_link_card_service.rb' - - 'app/services/resolve_url_service.rb' - 'config/initializers/twitter_regex.rb' - - 'lib/mastodon/snowflake.rb' + - 'lib/mastodon/migration_warning.rb' - 'spec/controllers/api/base_controller_spec.rb' # This cop supports safe autocorrection (--autocorrect). @@ -2273,12 +2216,6 @@ Style/NilLambda: Exclude: - 'config/initializers/paperclip.rb' -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: MinDigits, Strict, AllowedNumbers, AllowedPatterns. -Style/NumericLiterals: - Exclude: - - 'config/initializers/strong_migrations.rb' - # Configuration parameters: AllowedMethods. # AllowedMethods: respond_to_missing? Style/OptionalBooleanParameter: @@ -2388,7 +2325,6 @@ Style/Semicolon: Exclude: - 'spec/services/activitypub/process_status_update_service_spec.rb' - 'spec/validators/blacklisted_email_validator_spec.rb' - - 'spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb' # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. diff --git a/Gemfile b/Gemfile index 3c4ad3555..3301b83cc 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ # frozen_string_literal: true source 'https://rubygems.org' -ruby '>= 2.7.0', '< 3.3.0' +ruby '>= 3.0.0' gem 'pkg-config', '~> 1.5' @@ -9,10 +9,10 @@ gem 'puma', '~> 6.2' gem 'rails', '~> 6.1.7' gem 'sprockets', '~> 3.7.2' gem 'thor', '~> 1.2' -gem 'rack', '~> 2.2.6' +gem 'rack', '~> 2.2.7' gem 'haml-rails', '~>2.0' -gem 'pg', '~> 1.4' +gem 'pg', '~> 1.5' gem 'makara', '~> 0.5' gem 'pghero' gem 'dotenv-rails', '~> 2.8' @@ -30,7 +30,10 @@ gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'chewy', '~> 7.3' gem 'devise', '~> 4.9' -gem 'devise-two-factor', '~> 4.0' +# 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' group :pam_authentication, optional: true do gem 'devise_pam_authenticatable2', '~> 9.2' @@ -76,7 +79,7 @@ gem 'redcarpet', '~> 3.6' gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis'] gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'rqrcode', '~> 2.1' -gem 'ruby-progressbar', '~> 1.11' +gem 'ruby-progressbar', '~> 1.13' gem 'sanitize', '~> 6.0' gem 'scenic', '~> 1.7' gem 'sidekiq', '~> 6.5' @@ -121,7 +124,7 @@ group :test do gem 'capybara', '~> 3.39' gem 'climate_control' gem 'faker', '~> 3.2' - gem 'json-schema', '~> 3.0' + gem 'json-schema', '~> 4.0' gem 'rack-test', '~> 2.1' gem 'rails-controller-testing', '~> 1.0' gem 'rspec_junit_formatter', '~> 0.6' diff --git a/Gemfile.lock b/Gemfile.lock index 2a67abf27..7cf23180e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -27,6 +27,18 @@ 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: @@ -104,12 +116,12 @@ GEM activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) ast (2.4.2) - attr_encrypted (3.1.0) + attr_encrypted (4.0.0) encryptor (~> 3.0.0) attr_required (1.0.1) awrence (1.2.1) aws-eventstream (1.2.0) - aws-partitions (1.743.0) + aws-partitions (1.752.0) aws-sdk-core (3.171.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) @@ -118,7 +130,7 @@ GEM aws-sdk-kms (1.63.0) aws-sdk-core (~> 3, >= 3.165.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.120.1) + aws-sdk-s3 (1.121.0) aws-sdk-core (~> 3, >= 3.165.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.4) @@ -142,7 +154,7 @@ GEM blurhash (0.1.7) bootsnap (1.16.0) msgpack (~> 1.2) - brakeman (5.4.0) + brakeman (5.4.1) browser (5.3.1) brpoplpush-redis_script (0.1.3) concurrent-ruby (~> 1.0, >= 1.0.5) @@ -156,7 +168,7 @@ GEM i18n rake (>= 10.0.0) sshkit (>= 1.9.0) - capistrano-bundler (2.0.1) + capistrano-bundler (2.1.0) capistrano (~> 3.1) capistrano-rails (1.6.2) capistrano (~> 3.1) @@ -179,7 +191,7 @@ GEM activesupport cbor (0.5.9.6) charlock_holmes (0.7.7) - chewy (7.3.0) + chewy (7.3.2) activesupport (>= 5.2) elasticsearch (>= 7.12.0, < 7.14.0) elasticsearch-dsl @@ -189,29 +201,23 @@ GEM coderay (1.1.3) color_diff (0.1) concurrent-ruby (1.2.2) - connection_pool (2.3.0) + connection_pool (2.4.0) cose (1.3.0) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) crack (0.4.5) rexml crass (1.0.6) - css_parser (1.12.0) + css_parser (1.14.0) addressable date (3.3.3) - debug_inspector (1.0.0) + debug_inspector (1.1.0) devise (4.9.2) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-two-factor (4.0.2) - activesupport (< 7.1) - attr_encrypted (>= 1.3, < 4, != 2) - devise (~> 4.0) - railties (< 7.1) - rotp (~> 6.0) devise_pam_authenticatable2 (9.2.0) devise (>= 4.0.0) rpam2 (~> 4.0) @@ -241,7 +247,7 @@ GEM erubi (1.12.0) et-orbi (1.2.7) tzinfo - excon (0.95.0) + excon (0.99.0) fabrication (2.30.0) faker (3.2.0) i18n (>= 1.8.11, < 2) @@ -314,7 +320,7 @@ GEM hashie (5.0.0) hcaptcha (7.1.0) json - highline (2.0.3) + highline (2.1.0) hiredis (0.6.3) hkdf (0.3.0) htmlentities (4.3.4) @@ -364,7 +370,7 @@ GEM json-ld-preloaded (3.2.2) json-ld (~> 3.2) rdf (~> 3.2) - json-schema (3.0.0) + json-schema (4.0.0) addressable (>= 2.8) jsonapi-renderer (0.2.2) jwt (2.7.0) @@ -380,8 +386,8 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - launchy (2.5.0) - addressable (~> 2.7) + launchy (2.5.2) + addressable (~> 2.8) letter_opener (1.8.1) launchy (>= 2.2, < 3) letter_opener_web (2.0.0) @@ -416,11 +422,11 @@ GEM method_source (1.0.0) mime-types (3.4.1) mime-types-data (~> 3.2015) - mime-types-data (3.2022.0105) + mime-types-data (3.2023.0218.1) mini_mime (1.1.2) mini_portile2 (2.8.1) minitest (5.18.0) - msgpack (1.6.0) + msgpack (1.7.0) multi_json (1.15.0) multipart-post (2.3.0) net-http (0.3.2) @@ -437,7 +443,7 @@ GEM net-ssh (>= 2.6.5, < 8.0.0) net-smtp (0.3.3) net-protocol - net-ssh (7.0.1) + net-ssh (7.1.0) nio4r (2.5.9) nokogiri (1.14.3) mini_portile2 (~> 2.8.0) @@ -480,18 +486,18 @@ GEM openssl (> 2.0) orm_adapter (0.5.0) ox (2.14.16) - parallel (1.22.1) - parser (3.2.2.0) + parallel (1.23.0) + parser (3.2.2.1) ast (~> 2.4.1) parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.4.6) - pghero (3.3.2) + pg (1.5.2) + pghero (3.3.3) activerecord (>= 6) pkg-config (1.5.1) posix-spawn (0.3.15) - premailer (1.18.0) + premailer (1.21.0) addressable css_parser (>= 1.12.0) htmlentities (>= 4.0.0) @@ -501,13 +507,13 @@ GEM premailer (~> 1.7, >= 1.7.9) private_address_check (0.5.0) public_suffix (5.0.1) - puma (6.2.1) + puma (6.2.2) nio4r (~> 2.0) pundit (2.3.0) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.6.2) - rack (2.2.6.4) + rack (2.2.7) rack-attack (6.6.1) rack (>= 1.0, < 3) rack-cors (2.0.1) @@ -567,25 +573,25 @@ GEM redis (>= 4) redlock (1.3.2) redis (>= 3.0.0, < 6.0) - regexp_parser (2.7.0) + regexp_parser (2.8.0) request_store (1.5.1) rack (>= 1.4) responders (3.1.0) actionpack (>= 5.2) railties (>= 5.2) rexml (3.2.5) - rotp (6.2.0) + rotp (6.2.2) rpam2 (4.0.2) rqrcode (2.1.2) chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) - rspec-core (3.12.1) + rspec-core (3.12.2) rspec-support (~> 3.12.0) - rspec-expectations (3.12.2) + rspec-expectations (3.12.3) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) - rspec-mocks (3.12.3) + rspec-mocks (3.12.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) rspec-rails (6.0.1) @@ -603,7 +609,7 @@ GEM rspec_chunked (0.6) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.49.0) + rubocop (1.50.2) json (~> 2.3) parallel (~> 1.10) parser (>= 3.2.0.0) @@ -615,7 +621,7 @@ GEM unicode-display_width (>= 2.4.0, < 3.0) rubocop-ast (1.28.0) parser (>= 3.2.1.0) - rubocop-capybara (2.17.1) + rubocop-capybara (2.18.0) rubocop (~> 1.41) rubocop-performance (1.17.1) rubocop (>= 1.7.0, < 2.0) @@ -771,6 +777,7 @@ 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) @@ -792,7 +799,7 @@ DEPENDENCIES concurrent-ruby connection_pool devise (~> 4.9) - devise-two-factor (~> 4.0) + devise-two-factor! devise_pam_authenticatable2 (~> 9.2) discard (~> 1.2) doorkeeper (~> 5.6) @@ -817,7 +824,7 @@ DEPENDENCIES idn-ruby json-ld json-ld-preloaded (~> 3.2) - json-schema (~> 3.0) + json-schema (~> 4.0) kaminari (~> 1.2) kt-paperclip (~> 7.1)! letter_opener (~> 1.8) @@ -840,7 +847,7 @@ DEPENDENCIES omniauth_openid_connect (~> 0.6.1) ox (~> 2.14) parslet - pg (~> 1.4) + pg (~> 1.5) pghero pkg-config (~> 1.5) posix-spawn @@ -849,7 +856,7 @@ DEPENDENCIES public_suffix (~> 5.0) puma (~> 6.2) pundit (~> 2.3) - rack (~> 2.2.6) + rack (~> 2.2.7) rack-attack (~> 6.6) rack-cors (~> 2.0) rack-test (~> 2.1) @@ -871,7 +878,7 @@ DEPENDENCIES rubocop-performance rubocop-rails rubocop-rspec - ruby-progressbar (~> 1.11) + ruby-progressbar (~> 1.13) sanitize (~> 6.0) scenic (~> 1.7) sidekiq (~> 6.5) diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 104348614..c4b7e9c9d 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -8,7 +8,7 @@ class AboutController < ApplicationController before_action :set_instance_presenter def show - expires_in 0, public: true unless user_signed_in? + expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? end private diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 4d03a04b7..929bb54aa 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -7,8 +7,9 @@ class AccountsController < ApplicationController include AccountControllerConcern include SignatureAuthentication + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? } - before_action :set_cache_headers skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) } skip_before_action :require_functional!, unless: :whitelist_mode? @@ -16,7 +17,7 @@ class AccountsController < ApplicationController def show respond_to do |format| format.html do - expires_in 0, public: true unless user_signed_in? + expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.hour) unless user_signed_in? @rss_url = rss_url end diff --git a/app/controllers/activitypub/base_controller.rb b/app/controllers/activitypub/base_controller.rb index b8a7e0ab9..388d4b9e1 100644 --- a/app/controllers/activitypub/base_controller.rb +++ b/app/controllers/activitypub/base_controller.rb @@ -7,10 +7,6 @@ class ActivityPub::BaseController < Api::BaseController private - def set_cache_headers - response.headers['Vary'] = 'Signature' if authorized_fetch_mode? - end - def skip_temporary_suspension_response? false end diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index 23d874071..4ed59388f 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -4,11 +4,12 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController include SignatureVerification include AccountOwnedConcern + vary_by -> { 'Signature' if authorized_fetch_mode? } + before_action :require_account_signature!, if: :authorized_fetch_mode? before_action :set_items before_action :set_size before_action :set_type - before_action :set_cache_headers def show expires_in 3.minutes, public: public_fetch_mode? diff --git a/app/controllers/activitypub/followers_synchronizations_controller.rb b/app/controllers/activitypub/followers_synchronizations_controller.rb index 4e445bcb1..976caa344 100644 --- a/app/controllers/activitypub/followers_synchronizations_controller.rb +++ b/app/controllers/activitypub/followers_synchronizations_controller.rb @@ -4,9 +4,10 @@ class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseContro include SignatureVerification include AccountOwnedConcern + vary_by -> { 'Signature' if authorized_fetch_mode? } + before_action :require_account_signature! before_action :set_items - before_action :set_cache_headers def show expires_in 0, public: false diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index 60d201f76..bf10ba762 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -6,9 +6,10 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController include SignatureVerification include AccountOwnedConcern + vary_by -> { 'Signature' if authorized_fetch_mode? || page_requested? } + before_action :require_account_signature!, if: :authorized_fetch_mode? before_action :set_statuses - before_action :set_cache_headers def show if page_requested? @@ -16,6 +17,7 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController else expires_in(3.minutes, public: public_fetch_mode?) end + render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end @@ -80,8 +82,4 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController def set_account @account = params[:account_username].present? ? Account.find_local!(username_param) : Account.representative end - - def set_cache_headers - response.headers['Vary'] = 'Signature' if authorized_fetch_mode? || page_requested? - end end diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index 8e0f9de2e..c38ff89d1 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -7,9 +7,10 @@ class ActivityPub::RepliesController < ActivityPub::BaseController DESCENDANTS_LIMIT = 60 + vary_by -> { 'Signature' if authorized_fetch_mode? } + before_action :require_account_signature!, if: :authorized_fetch_mode? before_action :set_status - before_action :set_cache_headers before_action :set_replies def index diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index c645ce12b..a71bb6129 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -9,6 +9,8 @@ module Admin before_action :set_pack before_action :set_body_classes + before_action :set_cache_headers + after_action :verify_authorized private @@ -21,6 +23,10 @@ module Admin use_pack 'admin' end + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end + def set_user @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 41f3ce2ee..2629ab782 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -6,13 +6,14 @@ class Api::BaseController < ApplicationController include RateLimitHeaders include AccessTokenTrackingConcern + include ApiCachingConcern - skip_before_action :store_current_location skip_before_action :require_functional!, unless: :whitelist_mode? before_action :require_authenticated_user!, if: :disallow_unauthenticated_api_access? before_action :require_not_suspended! - before_action :set_cache_headers + + vary_by 'Authorization' protect_from_forgery with: :null_session @@ -148,10 +149,6 @@ class Api::BaseController < ApplicationController doorkeeper_authorize!(*scopes) if doorkeeper_token end - def set_cache_headers - response.headers['Cache-Control'] = 'private, no-store' - end - def disallow_unauthenticated_api_access? ENV['DISALLOW_UNAUTHENTICATED_API_ACCESS'] == 'true' || Rails.configuration.x.whitelist_mode end diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb index 68952de89..1a996d362 100644 --- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb @@ -6,6 +6,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController after_action :insert_pagination_headers def index + cache_if_unauthenticated! @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer end diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb index 0a4d2ae7b..6e6ebae43 100644 --- a/app/controllers/api/v1/accounts/following_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb @@ -6,6 +6,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController after_action :insert_pagination_headers def index + cache_if_unauthenticated! @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer end diff --git a/app/controllers/api/v1/accounts/lookup_controller.rb b/app/controllers/api/v1/accounts/lookup_controller.rb index 8597f891d..6d6339878 100644 --- a/app/controllers/api/v1/accounts/lookup_controller.rb +++ b/app/controllers/api/v1/accounts/lookup_controller.rb @@ -5,6 +5,7 @@ class Api::V1::Accounts::LookupController < Api::BaseController before_action :set_account def show + cache_if_unauthenticated! render json: @account, serializer: REST::AccountSerializer end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 7ed48cf65..51f541bd2 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -7,6 +7,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) } def index + cache_if_unauthenticated! @statuses = load_statuses render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 7dff66efa..8af4242ba 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -18,6 +18,7 @@ class Api::V1::AccountsController < Api::BaseController override_rate_limit_headers :follow, family: :follows def show + cache_if_unauthenticated! render json: @account, serializer: REST::AccountSerializer end diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb index 08b3474cc..76bc2b18a 100644 --- a/app/controllers/api/v1/custom_emojis_controller.rb +++ b/app/controllers/api/v1/custom_emojis_controller.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true class Api::V1::CustomEmojisController < Api::BaseController - skip_before_action :set_cache_headers + vary_by '', unless: :disallow_unauthenticated_api_access? def index - expires_in 3.minutes, public: true + cache_even_if_authenticated! unless disallow_unauthenticated_api_access? render_with_cache(each_serializer: REST::CustomEmojiSerializer) { CustomEmoji.listed.includes(:category) } end end diff --git a/app/controllers/api/v1/directories_controller.rb b/app/controllers/api/v1/directories_controller.rb index c91543e3a..c0585e859 100644 --- a/app/controllers/api/v1/directories_controller.rb +++ b/app/controllers/api/v1/directories_controller.rb @@ -5,6 +5,7 @@ class Api::V1::DirectoriesController < Api::BaseController before_action :set_accounts def show + cache_if_unauthenticated! render json: @accounts, each_serializer: REST::AccountSerializer end diff --git a/app/controllers/api/v1/instances/activity_controller.rb b/app/controllers/api/v1/instances/activity_controller.rb index bad61425a..3d55d990a 100644 --- a/app/controllers/api/v1/instances/activity_controller.rb +++ b/app/controllers/api/v1/instances/activity_controller.rb @@ -3,11 +3,12 @@ class Api::V1::Instances::ActivityController < Api::BaseController before_action :require_enabled_api! - skip_before_action :set_cache_headers skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + vary_by '' + def show - expires_in 1.day, public: true + cache_even_if_authenticated! render_with_cache json: :activity, expires_in: 1.day end diff --git a/app/controllers/api/v1/instances/domain_blocks_controller.rb b/app/controllers/api/v1/instances/domain_blocks_controller.rb index 37a6906fb..e954c4589 100644 --- a/app/controllers/api/v1/instances/domain_blocks_controller.rb +++ b/app/controllers/api/v1/instances/domain_blocks_controller.rb @@ -6,8 +6,15 @@ class Api::V1::Instances::DomainBlocksController < Api::BaseController before_action :require_enabled_api! before_action :set_domain_blocks + vary_by '', if: -> { Setting.show_domain_blocks == 'all' } + def index - expires_in 3.minutes, public: true + if Setting.show_domain_blocks == 'all' + cache_even_if_authenticated! + else + cache_if_unauthenticated! + end + render json: @domain_blocks, each_serializer: REST::DomainBlockSerializer, with_comment: (Setting.show_domain_blocks_rationale == 'all' || (Setting.show_domain_blocks_rationale == 'users' && user_signed_in?)) end diff --git a/app/controllers/api/v1/instances/extended_descriptions_controller.rb b/app/controllers/api/v1/instances/extended_descriptions_controller.rb index c72e16cff..a0665725b 100644 --- a/app/controllers/api/v1/instances/extended_descriptions_controller.rb +++ b/app/controllers/api/v1/instances/extended_descriptions_controller.rb @@ -2,11 +2,19 @@ class Api::V1::Instances::ExtendedDescriptionsController < Api::BaseController skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + skip_around_action :set_locale before_action :set_extended_description + vary_by '' + + # Override `current_user` to avoid reading session cookies unless in whitelist mode + def current_user + super if whitelist_mode? + end + def show - expires_in 3.minutes, public: true + cache_even_if_authenticated! render json: @extended_description, serializer: REST::ExtendedDescriptionSerializer end diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb index 2877fec52..70281362a 100644 --- a/app/controllers/api/v1/instances/peers_controller.rb +++ b/app/controllers/api/v1/instances/peers_controller.rb @@ -3,11 +3,18 @@ class Api::V1::Instances::PeersController < Api::BaseController before_action :require_enabled_api! - skip_before_action :set_cache_headers skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + skip_around_action :set_locale + + vary_by '' + + # Override `current_user` to avoid reading session cookies unless in whitelist mode + def current_user + super if whitelist_mode? + end def index - expires_in 1.day, public: true + cache_even_if_authenticated! render_with_cache(expires_in: 1.day) { Instance.where.not(domain: DomainBlock.select(:domain)).pluck(:domain) } end diff --git a/app/controllers/api/v1/instances/privacy_policies_controller.rb b/app/controllers/api/v1/instances/privacy_policies_controller.rb index dbd69f54d..36889f733 100644 --- a/app/controllers/api/v1/instances/privacy_policies_controller.rb +++ b/app/controllers/api/v1/instances/privacy_policies_controller.rb @@ -5,8 +5,10 @@ class Api::V1::Instances::PrivacyPoliciesController < Api::BaseController before_action :set_privacy_policy + vary_by '' + def show - expires_in 1.day, public: true + cache_even_if_authenticated! render json: @privacy_policy, serializer: REST::PrivacyPolicySerializer end diff --git a/app/controllers/api/v1/instances/rules_controller.rb b/app/controllers/api/v1/instances/rules_controller.rb index 93cf3c759..d3eeca326 100644 --- a/app/controllers/api/v1/instances/rules_controller.rb +++ b/app/controllers/api/v1/instances/rules_controller.rb @@ -2,10 +2,19 @@ class Api::V1::Instances::RulesController < Api::BaseController skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + skip_around_action :set_locale before_action :set_rules + vary_by '' + + # Override `current_user` to avoid reading session cookies unless in whitelist mode + def current_user + super if whitelist_mode? + end + def index + cache_even_if_authenticated! render json: @rules, each_serializer: REST::RuleSerializer end diff --git a/app/controllers/api/v1/instances/translation_languages_controller.rb b/app/controllers/api/v1/instances/translation_languages_controller.rb index 3910a499e..c4680cccb 100644 --- a/app/controllers/api/v1/instances/translation_languages_controller.rb +++ b/app/controllers/api/v1/instances/translation_languages_controller.rb @@ -5,8 +5,10 @@ class Api::V1::Instances::TranslationLanguagesController < Api::BaseController before_action :set_languages + vary_by '' + def show - expires_in 1.day, public: true + cache_even_if_authenticated! render json: @languages end diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb index 913319a86..5a6701ff9 100644 --- a/app/controllers/api/v1/instances_controller.rb +++ b/app/controllers/api/v1/instances_controller.rb @@ -1,11 +1,18 @@ # frozen_string_literal: true class Api::V1::InstancesController < Api::BaseController - skip_before_action :set_cache_headers skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + skip_around_action :set_locale + + vary_by '' + + # Override `current_user` to avoid reading session cookies unless in whitelist mode + def current_user + super if whitelist_mode? + end def show - expires_in 3.minutes, public: true + cache_even_if_authenticated! render_with_cache json: InstancePresenter.new, serializer: REST::V1::InstanceSerializer, root: 'instance' end end diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb index 6435e9f0d..ffc70a849 100644 --- a/app/controllers/api/v1/polls_controller.rb +++ b/app/controllers/api/v1/polls_controller.rb @@ -8,6 +8,7 @@ class Api::V1::PollsController < Api::BaseController before_action :refresh_poll def show + cache_if_unauthenticated! render json: @poll, serializer: REST::PollSerializer, include_results: true end diff --git a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb index b138fa265..73eb11e71 100644 --- a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb @@ -8,6 +8,7 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController after_action :insert_pagination_headers def index + cache_if_unauthenticated! @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer end diff --git a/app/controllers/api/v1/statuses/histories_controller.rb b/app/controllers/api/v1/statuses/histories_controller.rb index 7fe73a6f5..dff2425d0 100644 --- a/app/controllers/api/v1/statuses/histories_controller.rb +++ b/app/controllers/api/v1/statuses/histories_controller.rb @@ -7,6 +7,7 @@ class Api::V1::Statuses::HistoriesController < Api::BaseController before_action :set_status def show + cache_if_unauthenticated! render json: @status.edits.includes(:account, status: [:account]), each_serializer: REST::StatusEditSerializer end diff --git a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb index 4b545f982..41672e753 100644 --- a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb @@ -8,6 +8,7 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController after_action :insert_pagination_headers def index + cache_if_unauthenticated! @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 8dcf6331e..960f8cf76 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -24,11 +24,14 @@ class Api::V1::StatusesController < Api::BaseController DESCENDANTS_DEPTH_LIMIT = 20 def show + cache_if_unauthenticated! @status = cache_collection([@status], Status).first render json: @status, serializer: REST::StatusSerializer end def context + cache_if_unauthenticated! + ancestors_limit = CONTEXT_LIMIT descendants_limit = CONTEXT_LIMIT descendants_depth_limit = nil diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb index a08fd2187..284ec8593 100644 --- a/app/controllers/api/v1/tags_controller.rb +++ b/app/controllers/api/v1/tags_controller.rb @@ -8,6 +8,7 @@ class Api::V1::TagsController < Api::BaseController override_rate_limit_headers :follow, family: :follows def show + cache_if_unauthenticated! render json: @tag, serializer: REST::TagSerializer end diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index 4675af921..6af504ff6 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -5,6 +5,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } def show + cache_if_unauthenticated! @statuses = load_statuses render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) end diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 64a1db58d..9cd7b9904 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -5,6 +5,7 @@ class Api::V1::Timelines::TagController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } def show + cache_if_unauthenticated! @statuses = load_statuses render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) end diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb index 3ce20fb78..57cfa0b7e 100644 --- a/app/controllers/api/v1/trends/links_controller.rb +++ b/app/controllers/api/v1/trends/links_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::V1::Trends::LinksController < Api::BaseController + vary_by 'Authorization, Accept-Language' + before_action :set_links after_action :insert_pagination_headers @@ -8,6 +10,7 @@ class Api::V1::Trends::LinksController < Api::BaseController DEFAULT_LINKS_LIMIT = 10 def index + cache_if_unauthenticated! render json: @links, each_serializer: REST::Trends::LinkSerializer end diff --git a/app/controllers/api/v1/trends/statuses_controller.rb b/app/controllers/api/v1/trends/statuses_controller.rb index 3aab92477..c186864c3 100644 --- a/app/controllers/api/v1/trends/statuses_controller.rb +++ b/app/controllers/api/v1/trends/statuses_controller.rb @@ -1,11 +1,14 @@ # frozen_string_literal: true class Api::V1::Trends::StatusesController < Api::BaseController + vary_by 'Authorization, Accept-Language' + before_action :set_statuses after_action :insert_pagination_headers def index + cache_if_unauthenticated! render json: @statuses, each_serializer: REST::StatusSerializer end diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index 9dd9abdfe..6cc8194de 100644 --- a/app/controllers/api/v1/trends/tags_controller.rb +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -8,6 +8,7 @@ class Api::V1::Trends::TagsController < Api::BaseController DEFAULT_TAGS_LIMIT = (ENV['MAX_TRENDING_TAGS'] || 10).to_i def index + cache_if_unauthenticated! render json: @tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@tags, current_user&.account_id) end diff --git a/app/controllers/api/v2/instances_controller.rb b/app/controllers/api/v2/instances_controller.rb index bcd90cff2..8346e2883 100644 --- a/app/controllers/api/v2/instances_controller.rb +++ b/app/controllers/api/v2/instances_controller.rb @@ -2,7 +2,7 @@ class Api::V2::InstancesController < Api::V1::InstancesController def show - expires_in 3.minutes, public: true + cache_even_if_authenticated! render_with_cache json: InstancePresenter.new, serializer: REST::InstanceSerializer, root: 'instance' end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 906761f6f..7c09040fb 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -21,6 +21,8 @@ class ApplicationController < ActionController::Base helper_method :omniauth_only? helper_method :sso_account_settings helper_method :whitelist_mode? + helper_method :body_class_string + helper_method :skip_csrf_meta_tags? rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request rescue_from Mastodon::NotPermittedError, with: :forbidden @@ -37,9 +39,11 @@ class ApplicationController < ActionController::Base service_unavailable end - before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? + before_action :store_referrer, except: :raise_not_found, if: :devise_controller? before_action :require_functional!, if: :user_signed_in? + before_action :set_cache_control_defaults + skip_before_action :verify_authenticity_token, only: :raise_not_found def raise_not_found @@ -56,14 +60,25 @@ class ApplicationController < ActionController::Base !authorized_fetch_mode? end - def store_current_location - store_location_for(:user, request.url) unless [:json, :rss].include?(request.format&.to_sym) + def store_referrer + return if request.referer.blank? + + redirect_uri = URI(request.referer) + return if redirect_uri.path.start_with?('/auth') + + stored_url = redirect_uri.to_s if redirect_uri.host == request.host && redirect_uri.port == request.port + + store_location_for(:user, stored_url) end def require_functional! redirect_to edit_user_registration_path unless current_user.functional? end + def skip_csrf_meta_tags? + false + end + def after_sign_out_path_for(_resource_or_scope) if ENV['OMNIAUTH_ONLY'] == 'true' && ENV['OIDC_ENABLED'] == 'true' '/auth/auth/openid_connect/logout' @@ -127,7 +142,7 @@ class ApplicationController < ActionController::Base end def sso_account_settings - ENV.fetch('SSO_ACCOUNT_SETTINGS') + ENV.fetch('SSO_ACCOUNT_SETTINGS', nil) end def current_account @@ -142,6 +157,10 @@ class ApplicationController < ActionController::Base @current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present? end + def body_class_string + @body_classes || '' + end + def respond_with_error(code) respond_to do |format| format.any do @@ -151,4 +170,8 @@ class ApplicationController < ActionController::Base format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code } end end + + def set_cache_control_defaults + response.cache_control.replace(private: true, no_store: true) + end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index d2f1bea93..2322de465 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -157,6 +157,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController end def set_cache_headers - response.headers['Cache-Control'] = 'private, no-store' + response.cache_control.replace(private: true, no_store: true) end end diff --git a/app/controllers/concerns/api_caching_concern.rb b/app/controllers/concerns/api_caching_concern.rb new file mode 100644 index 000000000..705abce80 --- /dev/null +++ b/app/controllers/concerns/api_caching_concern.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module ApiCachingConcern + extend ActiveSupport::Concern + + def cache_if_unauthenticated! + expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? + end + + def cache_even_if_authenticated! + expires_in(5.minutes, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless whitelist_mode? + end +end diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb index a5a9ba3e1..55ebe1bd6 100644 --- a/app/controllers/concerns/cache_concern.rb +++ b/app/controllers/concerns/cache_concern.rb @@ -155,8 +155,30 @@ module CacheConcern end end + class_methods do + def vary_by(value, **kwargs) + before_action(**kwargs) do |controller| + response.headers['Vary'] = value.respond_to?(:call) ? controller.instance_exec(&value) : value + end + end + end + + included do + after_action :enforce_cache_control! + end + + # Prevents high-entropy headers such as `Cookie`, `Signature` or `Authorization` + # from being used as cache keys, while allowing to `Vary` on them (to not serve + # anonymous cached data to authenticated requests when authentication matters) + def enforce_cache_control! + vary = response.headers['Vary']&.split&.map { |x| x.strip.downcase } + return unless vary.present? && %w(cookie authorization signature).any? { |header| vary.include?(header) && request.headers[header].present? } + + response.cache_control.replace(private: true, no_store: true) + end + def render_with_cache(**options) - raise ArgumentError, 'only JSON render calls are supported' unless options.key?(:json) || block_given? + raise ArgumentError, 'Only JSON render calls are supported' unless options.key?(:json) || block_given? key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields].nil? ? nil : options[:fields].join(',')].compact.join(':') expires_in = options.delete(:expires_in) || 3.minutes @@ -176,10 +198,6 @@ module CacheConcern end end - def set_cache_headers - response.headers['Vary'] = public_fetch_mode? ? 'Accept' : 'Accept, Signature' - end - def cache_collection(raw, klass) return raw unless klass.respond_to?(:with_includes) diff --git a/app/controllers/concerns/web_app_controller_concern.rb b/app/controllers/concerns/web_app_controller_concern.rb index 7ba7a57e3..96c31566e 100644 --- a/app/controllers/concerns/web_app_controller_concern.rb +++ b/app/controllers/concerns/web_app_controller_concern.rb @@ -7,6 +7,12 @@ module WebAppControllerConcern prepend_before_action :redirect_unauthenticated_to_permalinks! before_action :set_pack before_action :set_app_body_class + + vary_by 'Accept, Accept-Language, Cookie' + end + + def skip_csrf_meta_tags? + current_user.nil? end def set_app_body_class diff --git a/app/controllers/custom_css_controller.rb b/app/controllers/custom_css_controller.rb index 9270c467d..e7a02ea89 100644 --- a/app/controllers/custom_css_controller.rb +++ b/app/controllers/custom_css_controller.rb @@ -1,18 +1,8 @@ # frozen_string_literal: true -class CustomCssController < ApplicationController - skip_before_action :store_current_location - skip_before_action :require_functional! - skip_before_action :update_user_sign_in - skip_before_action :set_session_activity - - skip_around_action :set_locale - - before_action :set_cache_headers - +class CustomCssController < ActionController::Base # rubocop:disable Rails/ApplicationController def show expires_in 3.minutes, public: true - request.session_options[:skip] = true render content_type: 'text/css' end end diff --git a/app/controllers/disputes/base_controller.rb b/app/controllers/disputes/base_controller.rb index 7830c5524..f51f44c62 100644 --- a/app/controllers/disputes/base_controller.rb +++ b/app/controllers/disputes/base_controller.rb @@ -10,6 +10,7 @@ class Disputes::BaseController < ApplicationController before_action :set_body_classes before_action :authenticate_user! before_action :set_pack + before_action :set_cache_headers private @@ -20,4 +21,8 @@ class Disputes::BaseController < ApplicationController def set_body_classes @body_classes = 'admin' end + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end end diff --git a/app/controllers/emojis_controller.rb b/app/controllers/emojis_controller.rb index 41f1e1c5c..72bc56de0 100644 --- a/app/controllers/emojis_controller.rb +++ b/app/controllers/emojis_controller.rb @@ -2,15 +2,12 @@ class EmojisController < ApplicationController before_action :set_emoji - before_action :set_cache_headers + + vary_by -> { 'Signature' if authorized_fetch_mode? } def show - respond_to do |format| - format.json do - expires_in 3.minutes, public: true - render_with_cache json: @emoji, content_type: 'application/activity+json', serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter - end - end + expires_in 3.minutes, public: true + render_with_cache json: @emoji, content_type: 'application/activity+json', serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter end private diff --git a/app/controllers/filters/statuses_controller.rb b/app/controllers/filters/statuses_controller.rb index 86d11fcb9..97206c7ed 100644 --- a/app/controllers/filters/statuses_controller.rb +++ b/app/controllers/filters/statuses_controller.rb @@ -8,6 +8,7 @@ class Filters::StatusesController < ApplicationController before_action :set_status_filters before_action :set_pack before_action :set_body_classes + before_action :set_cache_headers PER_PAGE = 20 @@ -49,4 +50,8 @@ class Filters::StatusesController < ApplicationController def set_body_classes @body_classes = 'admin' end + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index 2ab3b0a74..203241aff 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -7,6 +7,7 @@ class FiltersController < ApplicationController before_action :set_filter, only: [:edit, :update, :destroy] before_action :set_pack before_action :set_body_classes + before_action :set_cache_headers def index @filters = current_account.custom_filters.includes(:keywords, :statuses).order(:phrase) @@ -59,4 +60,8 @@ class FiltersController < ApplicationController def set_body_classes @body_classes = 'admin' end + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 1f5ed30de..2e55cf6c3 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -5,8 +5,9 @@ class FollowerAccountsController < ApplicationController include SignatureVerification include WebAppControllerConcern + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? } - before_action :set_cache_headers skip_around_action :set_locale, if: -> { request.format == :json } skip_before_action :require_functional!, unless: :whitelist_mode? @@ -14,7 +15,7 @@ class FollowerAccountsController < ApplicationController def index respond_to do |format| format.html do - expires_in 0, public: true unless user_signed_in? + expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.hour) unless user_signed_in? end format.json do diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index febd13c97..2aa31bdf0 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -5,8 +5,9 @@ class FollowingAccountsController < ApplicationController include SignatureVerification include WebAppControllerConcern + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? } - before_action :set_cache_headers skip_around_action :set_locale, if: -> { request.format == :json } skip_before_action :require_functional!, unless: :whitelist_mode? @@ -14,7 +15,7 @@ class FollowingAccountsController < ApplicationController def index respond_to do |format| format.html do - expires_in 0, public: true unless user_signed_in? + expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.hour) unless user_signed_in? end format.json do diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index d8ee82a7a..ee940e670 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -6,7 +6,7 @@ class HomeController < ApplicationController before_action :set_instance_presenter def index - expires_in 0, public: true unless user_signed_in? + expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? end private diff --git a/app/controllers/instance_actors_controller.rb b/app/controllers/instance_actors_controller.rb index 0853897f2..8422d74bc 100644 --- a/app/controllers/instance_actors_controller.rb +++ b/app/controllers/instance_actors_controller.rb @@ -1,10 +1,13 @@ # frozen_string_literal: true -class InstanceActorsController < ApplicationController - include AccountControllerConcern +class InstanceActorsController < ActivityPub::BaseController + vary_by '' - skip_before_action :check_account_confirmation - skip_around_action :set_locale + serialization_scope nil + + before_action :set_account + skip_before_action :require_functional! + skip_before_action :update_user_sign_in def show expires_in 10.minutes, public: true diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 0b3c082dc..2db4bc5cb 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -8,6 +8,7 @@ class InvitesController < ApplicationController before_action :authenticate_user! before_action :set_pack before_action :set_body_classes + before_action :set_cache_headers def index authorize :invite, :create? @@ -54,4 +55,8 @@ class InvitesController < ApplicationController def set_body_classes @body_classes = 'admin' end + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end end diff --git a/app/controllers/manifests_controller.rb b/app/controllers/manifests_controller.rb index 960510f60..4fba9198f 100644 --- a/app/controllers/manifests_controller.rb +++ b/app/controllers/manifests_controller.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true -class ManifestsController < ApplicationController - skip_before_action :store_current_location - skip_before_action :require_functional! +class ManifestsController < ActionController::Base # rubocop:disable Rails/ApplicationController + # Prevent `active_model_serializer`'s `ActionController::Serialization` from calling `current_user` + # and thus re-issuing session cookies + serialization_scope nil def show expires_in 3.minutes, public: true diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 37c5dcb99..41e20add6 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -3,7 +3,6 @@ class MediaController < ApplicationController include Authorization - skip_before_action :store_current_location skip_before_action :require_functional!, unless: :whitelist_mode? before_action :authenticate_user!, if: :whitelist_mode? diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index f29b69a24..1b5486c12 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -6,7 +6,6 @@ class MediaProxyController < ApplicationController include Redisable include Lockable - skip_before_action :store_current_location skip_before_action :require_functional! before_action :authenticate_user!, if: :whitelist_mode? diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index d6e7d0800..62fc9c1b0 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -39,6 +39,6 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController end def set_cache_headers - response.headers['Cache-Control'] = 'private, no-store' + response.cache_control.replace(private: true, no_store: true) end end diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index b2564a791..efae7e35f 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -8,6 +8,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio before_action :set_pack before_action :require_not_suspended!, only: :destroy before_action :set_body_classes + before_action :set_cache_headers skip_before_action :require_functional! @@ -35,4 +36,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio def require_not_suspended! forbidden if current_account.suspended? end + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end end diff --git a/app/controllers/privacy_controller.rb b/app/controllers/privacy_controller.rb index 2c98bf3bf..070ee8a06 100644 --- a/app/controllers/privacy_controller.rb +++ b/app/controllers/privacy_controller.rb @@ -8,7 +8,7 @@ class PrivacyController < ApplicationController before_action :set_instance_presenter def show - expires_in 0, public: true if current_account.nil? + expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? end private diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb index 52cf1e0c1..f83098f73 100644 --- a/app/controllers/relationships_controller.rb +++ b/app/controllers/relationships_controller.rb @@ -8,6 +8,7 @@ class RelationshipsController < ApplicationController before_action :set_pack before_action :set_relationships, only: :show before_action :set_body_classes + before_action :set_cache_headers helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship? @@ -75,4 +76,8 @@ class RelationshipsController < ApplicationController def set_pack use_pack 'admin' end + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end end diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index bf17b918c..56aeb49aa 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -19,7 +19,7 @@ class Settings::BaseController < ApplicationController end def set_cache_headers - response.headers['Cache-Control'] = 'private, no-store' + response.cache_control.replace(private: true, no_store: true) end def require_not_suspended! diff --git a/app/controllers/statuses_cleanup_controller.rb b/app/controllers/statuses_cleanup_controller.rb index 0e7bb835f..3ed1860a0 100644 --- a/app/controllers/statuses_cleanup_controller.rb +++ b/app/controllers/statuses_cleanup_controller.rb @@ -7,6 +7,7 @@ class StatusesCleanupController < ApplicationController before_action :set_policy before_action :set_body_classes before_action :set_pack + before_action :set_cache_headers def show; end @@ -41,4 +42,8 @@ class StatusesCleanupController < ApplicationController def set_body_classes @body_classes = 'admin' end + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 15c081264..575808598 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -6,11 +6,12 @@ class StatusesController < ApplicationController include Authorization include AccountOwnedConcern + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_status before_action :set_instance_presenter before_action :redirect_to_original, only: :show - before_action :set_cache_headers before_action :set_body_classes, only: :embed after_action :set_link_headers @@ -29,7 +30,7 @@ class StatusesController < ApplicationController end format.json do - expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? + expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode? render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter end end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 4b747c9ad..7e249dbea 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -7,6 +7,8 @@ class TagsController < ApplicationController PAGE_SIZE = 20 PAGE_SIZE_MAX = 200 + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :authenticate_user!, if: :whitelist_mode? before_action :set_local @@ -19,7 +21,7 @@ class TagsController < ApplicationController def show respond_to do |format| format.html do - expires_in 0, public: true unless user_signed_in? + expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.hour) unless user_signed_in? end format.rss do diff --git a/app/controllers/well_known/host_meta_controller.rb b/app/controllers/well_known/host_meta_controller.rb index 2fd6bc7cc..201da9fbc 100644 --- a/app/controllers/well_known/host_meta_controller.rb +++ b/app/controllers/well_known/host_meta_controller.rb @@ -1,11 +1,9 @@ # frozen_string_literal: true module WellKnown - class HostMetaController < ActionController::Base + class HostMetaController < ActionController::Base # rubocop:disable Rails/ApplicationController include RoutingHelper - before_action { response.headers['Vary'] = 'Accept' } - def show @webfinger_template = "#{webfinger_url}?resource={uri}" expires_in 3.days, public: true diff --git a/app/controllers/well_known/nodeinfo_controller.rb b/app/controllers/well_known/nodeinfo_controller.rb index 11a699ebc..e20e8c62a 100644 --- a/app/controllers/well_known/nodeinfo_controller.rb +++ b/app/controllers/well_known/nodeinfo_controller.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true module WellKnown - class NodeInfoController < ActionController::Base + class NodeInfoController < ActionController::Base # rubocop:disable Rails/ApplicationController include CacheConcern - before_action { response.headers['Vary'] = 'Accept' } + # Prevent `active_model_serializer`'s `ActionController::Serialization` from calling `current_user` + # and thus re-issuing session cookies + serialization_scope nil def index expires_in 3.days, public: true diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 2b296ea3b..a06253f45 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module WellKnown - class WebfingerController < ActionController::Base + class WebfingerController < ActionController::Base # rubocop:disable Rails/ApplicationController include RoutingHelper before_action :set_account @@ -34,7 +34,12 @@ module WellKnown end def check_account_suspension - expires_in(3.minutes, public: true) && gone if @account.suspended_permanently? + gone if @account.suspended_permanently? + end + + def gone + expires_in(3.minutes, public: true) + head 410 end def bad_request @@ -46,9 +51,5 @@ module WellKnown expires_in(3.minutes, public: true) head 404 end - - def gone - head 410 - end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 1228ce36c..e3da47d9b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -155,20 +155,8 @@ module ApplicationHelper tag(:meta, content: content, property: property) end - def react_component(name, props = {}, &block) - if block.nil? - content_tag(:div, nil, data: { component: name.to_s.camelcase, props: Oj.dump(props) }) - else - content_tag(:div, data: { component: name.to_s.camelcase, props: Oj.dump(props) }, &block) - end - end - - def react_admin_component(name, props = {}) - content_tag(:div, nil, data: { 'admin-component': name.to_s.camelcase, props: Oj.dump({ locale: I18n.locale }.merge(props)) }) - end - def body_classes - output = (@body_classes || '').split + output = body_class_string.split output << "flavour-#{current_flavour.parameterize}" output << "skin-#{current_skin.parameterize}" output << 'system-font' if current_account&.user&.setting_system_font_ui diff --git a/app/helpers/instance_helper.rb b/app/helpers/instance_helper.rb index bedfe6f30..893afdd51 100644 --- a/app/helpers/instance_helper.rb +++ b/app/helpers/instance_helper.rb @@ -9,13 +9,17 @@ module InstanceHelper @site_hostname ||= Addressable::URI.parse("//#{Rails.configuration.x.local_domain}").display_uri.host end - def description_for_sign_up - prefix = if @invite.present? - I18n.t('auth.description.prefix_invited_by_user', name: @invite.user.account.username) - else - I18n.t('auth.description.prefix_sign_up') - end + def description_for_sign_up(invite = nil) + safe_join([description_prefix(invite), I18n.t('auth.description.suffix')], ' ') + end - safe_join([prefix, I18n.t('auth.description.suffix')], ' ') + private + + def description_prefix(invite) + if invite.present? + I18n.t('auth.description.prefix_invited_by_user', name: invite.user.account.username) + else + I18n.t('auth.description.prefix_sign_up') + end end end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 24362b61e..ce3ff094f 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -63,11 +63,11 @@ module JsonLdHelper uri.nil? || !uri.start_with?('http://', 'https://') end - def invalid_origin?(url) - return true if unsupported_uri_scheme?(url) + def non_matching_uri_hosts?(base_url, comparison_url) + return true if unsupported_uri_scheme?(comparison_url) - needle = Addressable::URI.parse(url).host - haystack = Addressable::URI.parse(@account.uri).host + needle = Addressable::URI.parse(comparison_url).host + haystack = Addressable::URI.parse(base_url).host !haystack.casecmp(needle).zero? end diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index bbf0a97fc..00e1e5178 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -201,7 +201,6 @@ module LanguagesHelper sma: ['Southern Sami', 'Åarjelsaemien Gïele'].freeze, smj: ['Lule Sami', 'Julevsámegiella'].freeze, szl: ['Silesian', 'ślůnsko godka'].freeze, - tai: ['Tai', 'ภาษาไท or ภาษาไต'].freeze, tok: ['Toki Pona', 'toki pona'].freeze, zba: ['Balaibalan', 'باليبلن'].freeze, zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze, diff --git a/app/helpers/media_component_helper.rb b/app/helpers/media_component_helper.rb new file mode 100644 index 000000000..a57d0b4b6 --- /dev/null +++ b/app/helpers/media_component_helper.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module MediaComponentHelper + def render_video_component(status, **options) + video = status.ordered_media_attachments.first + + meta = video.file.meta || {} + + component_params = { + sensitive: sensitive_viewer?(status, current_account), + src: full_asset_url(video.file.url(:original)), + preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), + alt: video.description, + blurhash: video.blurhash, + frameRate: meta.dig('original', 'frame_rate'), + inline: true, + media: [ + serialize_media_attachment(video), + ].as_json, + }.merge(**options) + + react_component :video, component_params do + render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } + end + end + + def render_audio_component(status, **options) + audio = status.ordered_media_attachments.first + + meta = audio.file.meta || {} + + component_params = { + src: full_asset_url(audio.file.url(:original)), + poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), + alt: audio.description, + backgroundColor: meta.dig('colors', 'background'), + foregroundColor: meta.dig('colors', 'foreground'), + accentColor: meta.dig('colors', 'accent'), + duration: meta.dig('original', 'duration'), + }.merge(**options) + + react_component :audio, component_params do + render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } + end + end + + def render_media_gallery_component(status, **options) + component_params = { + sensitive: sensitive_viewer?(status, current_account), + autoplay: prefers_autoplay?, + media: status.ordered_media_attachments.map { |a| serialize_media_attachment(a).as_json }, + }.merge(**options) + + react_component :media_gallery, component_params do + render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } + end + end + + def render_card_component(status, **options) + component_params = { + sensitive: sensitive_viewer?(status, current_account), + card: serialize_status_card(status).as_json, + }.merge(**options) + + react_component :card, component_params + end + + def render_poll_component(status, **options) + component_params = { + disabled: true, + poll: serialize_status_poll(status).as_json, + }.merge(**options) + + react_component :poll, component_params do + render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: prefers_autoplay? } + end + end + + private + + def serialize_media_attachment(attachment) + ActiveModelSerializers::SerializableResource.new( + attachment, + serializer: REST::MediaAttachmentSerializer + ) + end + + def serialize_status_card(status) + ActiveModelSerializers::SerializableResource.new( + status.preview_card, + serializer: REST::PreviewCardSerializer + ) + end + + def serialize_status_poll(status) + ActiveModelSerializers::SerializableResource.new( + status.preloadable_poll, + serializer: REST::PollSerializer, + scope: current_user, + scope_name: :current_user + ) + end + + def sensitive_viewer?(status, account) + if !account.nil? && account.id == status.account_id + status.sensitive + else + status.account.sensitized? || status.sensitive + end + end +end diff --git a/app/helpers/react_component_helper.rb b/app/helpers/react_component_helper.rb new file mode 100644 index 000000000..fc08de13d --- /dev/null +++ b/app/helpers/react_component_helper.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ReactComponentHelper + def react_component(name, props = {}, &block) + data = { component: name.to_s.camelcase, props: Oj.dump(props) } + if block.nil? + div_tag_with_data(data) + else + content_tag(:div, data: data, &block) + end + end + + def react_admin_component(name, props = {}) + data = { 'admin-component': name.to_s.camelcase, props: Oj.dump({ locale: I18n.locale }.merge(props)) } + div_tag_with_data(data) + end + + private + + def div_tag_with_data(data) + content_tag(:div, nil, data: data) + end +end diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index d1e3fddaf..9f8759367 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -105,94 +105,10 @@ module StatusesHelper end end - def sensitized?(status, account) - if !account.nil? && account.id == status.account_id - status.sensitive - else - status.account.sensitized? || status.sensitive - end - end - def embedded_view? params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION end - def render_video_component(status, **options) - video = status.ordered_media_attachments.first - - meta = video.file.meta || {} - - component_params = { - sensitive: sensitized?(status, current_account), - src: full_asset_url(video.file.url(:original)), - preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), - alt: video.description, - blurhash: video.blurhash, - frameRate: meta.dig('original', 'frame_rate'), - inline: true, - media: [ - ActiveModelSerializers::SerializableResource.new(video, serializer: REST::MediaAttachmentSerializer), - ].as_json, - }.merge(**options) - - react_component :video, component_params do - render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } - end - end - - def render_audio_component(status, **options) - audio = status.ordered_media_attachments.first - - meta = audio.file.meta || {} - - component_params = { - src: full_asset_url(audio.file.url(:original)), - poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), - alt: audio.description, - backgroundColor: meta.dig('colors', 'background'), - foregroundColor: meta.dig('colors', 'foreground'), - accentColor: meta.dig('colors', 'accent'), - duration: meta.dig('original', 'duration'), - }.merge(**options) - - react_component :audio, component_params do - render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } - end - end - - def render_media_gallery_component(status, **options) - component_params = { - sensitive: sensitized?(status, current_account), - autoplay: prefers_autoplay?, - media: status.ordered_media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }, - }.merge(**options) - - react_component :media_gallery, component_params do - render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } - end - end - - def render_card_component(status, **options) - component_params = { - sensitive: sensitized?(status, current_account), - maxDescription: 160, - card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json, - }.merge(**options) - - react_component :card, component_params - end - - def render_poll_component(status, **options) - component_params = { - disabled: true, - poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json, - }.merge(**options) - - react_component :poll, component_params do - render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: prefers_autoplay? } - end - end - def prefers_autoplay? ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif end diff --git a/app/javascript/images/elephant_ui_conversation.svg b/app/javascript/images/elephant_ui_conversation.svg new file mode 100644 index 000000000..f849b5959 --- /dev/null +++ b/app/javascript/images/elephant_ui_conversation.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index e1db44359..7e36bd22a 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -74,6 +74,7 @@ export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTIO export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS'; export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; +export const COMPOSE_FOCUS = 'COMPOSE_FOCUS'; const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, @@ -125,6 +126,15 @@ export function resetCompose() { }; } +export const focusCompose = (routerHistory, defaultText) => dispatch => { + dispatch({ + type: COMPOSE_FOCUS, + defaultText, + }); + + ensureComposeIsVisible(routerHistory); +}; + export function mentionCompose(account, routerHistory) { return (dispatch, getState) => { dispatch({ diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js index 56608f28b..605a457a2 100644 --- a/app/javascript/mastodon/actions/search.js +++ b/app/javascript/mastodon/actions/search.js @@ -135,8 +135,7 @@ export const showSearch = () => ({ type: SEARCH_SHOW, }); -export const openURL = routerHistory => (dispatch, getState) => { - const value = getState().getIn(['search', 'value']); +export const openURL = (value, history, onFailure) => (dispatch, getState) => { const signedIn = !!getState().getIn(['meta', 'me']); if (!signedIn) { @@ -148,15 +147,21 @@ export const openURL = routerHistory => (dispatch, getState) => { api(getState).get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => { if (response.data.accounts?.length > 0) { dispatch(importFetchedAccounts(response.data.accounts)); - routerHistory.push(`/@${response.data.accounts[0].acct}`); + history.push(`/@${response.data.accounts[0].acct}`); } else if (response.data.statuses?.length > 0) { dispatch(importFetchedStatuses(response.data.statuses)); - routerHistory.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`); + history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`); + } else if (onFailure) { + onFailure(); } dispatch(fetchSearchSuccess(response.data, value)); }).catch(err => { dispatch(fetchSearchFail(err)); + + if (onFailure) { + onFailure(); + } }); }; diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx index a8a47ecac..0e2295e3a 100644 --- a/app/javascript/mastodon/components/account.jsx +++ b/app/javascript/mastodon/components/account.jsx @@ -12,8 +12,8 @@ import Skeleton from 'mastodon/components/skeleton'; import { Link } from 'react-router-dom'; import { counterRenderer } from 'mastodon/components/common_counter'; import ShortNumber from 'mastodon/components/short_number'; -import Icon from 'mastodon/components/icon'; import classNames from 'classnames'; +import VerifiedBadge from 'mastodon/components/verified_badge'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -27,26 +27,6 @@ const messages = defineMessages({ block: { id: 'account.block', defaultMessage: 'Block @{name}' }, }); -class VerifiedBadge extends React.PureComponent { - - static propTypes = { - link: PropTypes.string.isRequired, - verifiedAt: PropTypes.string.isRequired, - }; - - render () { - const { link } = this.props; - - return ( - - - - - ); - } - -} - class Account extends ImmutablePureComponent { static propTypes = { diff --git a/app/javascript/mastodon/components/check.jsx b/app/javascript/mastodon/components/check.jsx index ee2ef1595..2fd0af740 100644 --- a/app/javascript/mastodon/components/check.jsx +++ b/app/javascript/mastodon/components/check.jsx @@ -1,8 +1,8 @@ import React from 'react'; const Check = () => ( - - + + ); diff --git a/app/javascript/mastodon/components/column_back_button.jsx b/app/javascript/mastodon/components/column_back_button.jsx index 5c5226b7e..12926bb25 100644 --- a/app/javascript/mastodon/components/column_back_button.jsx +++ b/app/javascript/mastodon/components/column_back_button.jsx @@ -12,13 +12,19 @@ export default class ColumnBackButton extends React.PureComponent { static propTypes = { multiColumn: PropTypes.bool, + onClick: PropTypes.func, }; handleClick = () => { - if (window.history && window.history.state) { - this.context.router.history.goBack(); + const { router } = this.context; + const { onClick } = this.props; + + if (onClick) { + onClick(); + } else if (window.history && window.history.state) { + router.history.goBack(); } else { - this.context.router.history.push('/'); + router.history.push('/'); } }; diff --git a/app/javascript/mastodon/components/edited_timestamp/index.jsx b/app/javascript/mastodon/components/edited_timestamp/index.jsx index 1513f9361..8a5417a7d 100644 --- a/app/javascript/mastodon/components/edited_timestamp/index.jsx +++ b/app/javascript/mastodon/components/edited_timestamp/index.jsx @@ -32,7 +32,7 @@ class EditedTimestamp extends React.PureComponent { renderHeader = items => { return ( - + ); }; diff --git a/app/javascript/mastodon/components/hashtag.jsx b/app/javascript/mastodon/components/hashtag.jsx index 94c61b654..8821c66f0 100644 --- a/app/javascript/mastodon/components/hashtag.jsx +++ b/app/javascript/mastodon/components/hashtag.jsx @@ -43,7 +43,7 @@ class SilentErrorBoundary extends React.Component { export const accountsCountRenderer = (displayNumber, pluralReady) => ( {displayNumber}, diff --git a/app/javascript/mastodon/components/logo.jsx b/app/javascript/mastodon/components/logo.jsx index ee5c22496..60e8f40b2 100644 --- a/app/javascript/mastodon/components/logo.jsx +++ b/app/javascript/mastodon/components/logo.jsx @@ -1,10 +1,15 @@ import React from 'react'; +import logo from 'mastodon/../images/logo.svg'; -const Logo = () => ( - +export const WordmarkLogo = () => ( + Mastodon ); -export default Logo; +export const SymbolLogo = () => ( + Mastodon +); + +export default WordmarkLogo; diff --git a/app/javascript/mastodon/components/short_number.jsx b/app/javascript/mastodon/components/short_number.jsx index 535c17727..861d0bc58 100644 --- a/app/javascript/mastodon/components/short_number.jsx +++ b/app/javascript/mastodon/components/short_number.jsx @@ -32,17 +32,14 @@ function ShortNumber({ value, renderer, children }) { const shortNumber = toShortNumber(value); const [, division] = shortNumber; - // eslint-disable-next-line eqeqeq if (children != null && renderer != null) { console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.'); } - // eslint-disable-next-line eqeqeq const customRenderer = children != null ? children : renderer; const displayNumber = ; - // eslint-disable-next-line eqeqeq return customRenderer != null ? customRenderer(displayNumber, pluralReady(value, division)) : displayNumber; diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 60a77a39c..cd8423b2f 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -68,6 +68,9 @@ class Status extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map, account: ImmutablePropTypes.map, + previousId: PropTypes.string, + nextInReplyToId: PropTypes.string, + rootId: PropTypes.string, onClick: PropTypes.func, onReply: PropTypes.func, onFavourite: PropTypes.func, @@ -309,10 +312,7 @@ class Status extends ImmutablePureComponent { }; render () { - let media = null; - let statusAvatar, prepend, rebloggedByText; - - const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture } = this.props; + const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props; let { status, account, ...other } = this.props; @@ -334,6 +334,8 @@ class Status extends ImmutablePureComponent { openMedia: this.handleHotkeyOpenMedia, }; + let media, statusAvatar, prepend, rebloggedByText; + if (hidden) { return ( @@ -345,7 +347,11 @@ class Status extends ImmutablePureComponent { ); } + const connectUp = previousId && previousId === status.get('in_reply_to_id'); + const connectToRoot = rootId && rootId === status.get('in_reply_to_id'); + const connectReply = nextInReplyToId && nextInReplyToId === status.get('id'); const matchedFilters = status.get('matched_filters'); + if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) { const minHandlers = this.props.muted ? {} : { moveUp: this.handleHotkeyMoveUp, @@ -519,7 +525,10 @@ class Status extends ImmutablePureComponent {
{prepend} -
+
+ {(connectReply || connectUp || connectToRoot) &&
} + + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 60f820bc5..dd589dac6 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -104,7 +104,7 @@ class StatusContent extends React.PureComponent { link.setAttribute('href', `/@${mention.get('acct')}`); } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); - link.setAttribute('href', `/tags/${link.text.slice(1)}`); + link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`); } else { link.setAttribute('title', link.href); link.classList.add('unhandled-link'); diff --git a/app/javascript/mastodon/components/verified_badge.jsx b/app/javascript/mastodon/components/verified_badge.jsx new file mode 100644 index 000000000..3d878d5dd --- /dev/null +++ b/app/javascript/mastodon/components/verified_badge.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'mastodon/components/icon'; + +class VerifiedBadge extends React.PureComponent { + + static propTypes = { + link: PropTypes.string.isRequired, + verifiedAt: PropTypes.string.isRequired, + }; + + render () { + const { link } = this.props; + + return ( + + + + + ); + } + +} + +export default VerifiedBadge; \ No newline at end of file diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index 580f409e9..f48316654 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -67,6 +67,7 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ status: getStatus(state, props), + nextInReplyToId: props.nextId ? state.getIn(['statuses', props.nextId, 'in_reply_to_id']) : null, pictureInPicture: getPictureInPicture(state, props), }); diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx index 72eb7e6b6..ff8dc5fa9 100644 --- a/app/javascript/mastodon/features/account/components/header.jsx +++ b/app/javascript/mastodon/features/account/components/header.jsx @@ -80,6 +80,7 @@ class Header extends ImmutablePureComponent { static contextTypes = { identity: PropTypes.object, + router: PropTypes.object, }; static propTypes = { @@ -101,11 +102,16 @@ class Header extends ImmutablePureComponent { onChangeLanguages: PropTypes.func.isRequired, onInteractionModal: PropTypes.func.isRequired, onOpenAvatar: PropTypes.func.isRequired, + onOpenURL: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, domain: PropTypes.string.isRequired, hidden: PropTypes.bool, }; + setRef = c => { + this.node = c; + }; + openEditProfile = () => { window.open('/settings/profile', '_blank'); }; @@ -162,6 +168,61 @@ class Header extends ImmutablePureComponent { }); }; + handleHashtagClick = e => { + const { router } = this.context; + const value = e.currentTarget.textContent.replace(/^#/, ''); + + if (router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + router.history.push(`/tags/${value}`); + } + }; + + handleMentionClick = e => { + const { router } = this.context; + const { onOpenURL } = this.props; + + if (router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + + const link = e.currentTarget; + + onOpenURL(link.href, router.history, () => { + window.location = link.href; + }); + } + }; + + _attachLinkEvents () { + const node = this.node; + + if (!node) { + return; + } + + const links = node.querySelectorAll('a'); + + let link; + + for (var i = 0; i < links.length; ++i) { + link = links[i]; + + if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { + link.addEventListener('click', this.handleHashtagClick, false); + } else if (link.classList.contains('mention')) { + link.addEventListener('click', this.handleMentionClick, false); + } + } + } + + componentDidMount () { + this._attachLinkEvents(); + } + + componentDidUpdate () { + this._attachLinkEvents(); + } + render () { const { account, hidden, intl, domain } = this.props; const { signedIn, permissions } = this.context.identity; @@ -360,7 +421,7 @@ class Header extends ImmutablePureComponent { {!(suspended || hidden) && (
-
+
{(account.get('id') !== me && signedIn) && } {account.get('note').length > 0 && account.get('note') !== '

' &&
} diff --git a/app/javascript/mastodon/features/account_timeline/components/header.jsx b/app/javascript/mastodon/features/account_timeline/components/header.jsx index c008f0342..e64deaefa 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.jsx +++ b/app/javascript/mastodon/features/account_timeline/components/header.jsx @@ -26,6 +26,7 @@ export default class Header extends ImmutablePureComponent { onChangeLanguages: PropTypes.func.isRequired, onInteractionModal: PropTypes.func.isRequired, onOpenAvatar: PropTypes.func.isRequired, + onOpenURL: PropTypes.func.isRequired, hideTabs: PropTypes.bool, domain: PropTypes.string.isRequired, hidden: PropTypes.bool, @@ -137,6 +138,7 @@ export default class Header extends ImmutablePureComponent { onChangeLanguages={this.handleChangeLanguages} onInteractionModal={this.handleInteractionModal} onOpenAvatar={this.handleOpenAvatar} + onOpenURL={this.props.onOpenURL} domain={this.props.domain} hidden={hidden} /> diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx index f53cd2480..419a9fa56 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx @@ -10,6 +10,7 @@ import { pinAccount, unpinAccount, } from '../../../actions/accounts'; +import { openURL } from 'mastodon/actions/search'; import { mentionCompose, directCompose, @@ -159,6 +160,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ })); }, + onOpenURL (url, routerHistory, onFailure) { + dispatch(openURL(url, routerHistory, onFailure)); + }, + }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index a40eb87e3..df11d2c97 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -20,6 +20,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { length } from 'stringz'; import { countableText } from '../util/counter'; import Icon from 'mastodon/components/icon'; +import classNames from 'classnames'; import { maxChars } from '../../../initial_state'; const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; @@ -71,6 +72,10 @@ class ComposeForm extends ImmutablePureComponent { autoFocus: false, }; + state = { + highlighted: false, + }; + handleChange = (e) => { this.props.onChange(e.target.value); }; @@ -144,6 +149,10 @@ class ComposeForm extends ImmutablePureComponent { this._updateFocusAndSelection({ }); } + componentWillUnmount () { + if (this.timeout) clearTimeout(this.timeout); + } + componentDidUpdate (prevProps) { this._updateFocusAndSelection(prevProps); } @@ -174,6 +183,8 @@ class ComposeForm extends ImmutablePureComponent { Promise.resolve().then(() => { this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); this.autosuggestTextarea.textarea.focus(); + this.setState({ highlighted: true }); + this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700); }).catch(console.error); } else if(prevProps.isSubmitting && !this.props.isSubmitting) { this.autosuggestTextarea.textarea.focus(); @@ -208,6 +219,7 @@ class ComposeForm extends ImmutablePureComponent { render () { const { intl, onPaste, autoFocus } = this.props; + const { highlighted } = this.state; const disabled = this.props.isSubmitting; let publishText = ''; @@ -246,41 +258,43 @@ class ComposeForm extends ImmutablePureComponent { />
- - +
+ + -
- - -
-
+
+ + +
+ -
-
- - - - - -
+
+
+ + + + + +
-
- +
+ +
diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx index 46723f5cc..7fff587d6 100644 --- a/app/javascript/mastodon/features/compose/components/search.jsx +++ b/app/javascript/mastodon/features/compose/components/search.jsx @@ -161,9 +161,9 @@ class Search extends React.PureComponent { handleURLClick = () => { const { router } = this.context; - const { onOpenURL } = this.props; + const { value, onOpenURL } = this.props; - onOpenURL(router.history); + onOpenURL(value, router.history); }; handleStatusSearch = () => { diff --git a/app/javascript/mastodon/features/compose/components/search_results.jsx b/app/javascript/mastodon/features/compose/components/search_results.jsx index 1dccd950c..8fafd88b0 100644 --- a/app/javascript/mastodon/features/compose/components/search_results.jsx +++ b/app/javascript/mastodon/features/compose/components/search_results.jsx @@ -126,7 +126,7 @@ class SearchResults extends ImmutablePureComponent {
- +
{accounts} diff --git a/app/javascript/mastodon/features/compose/containers/search_container.js b/app/javascript/mastodon/features/compose/containers/search_container.js index 3ee55fae5..3d2d728c8 100644 --- a/app/javascript/mastodon/features/compose/containers/search_container.js +++ b/app/javascript/mastodon/features/compose/containers/search_container.js @@ -34,8 +34,8 @@ const mapDispatchToProps = dispatch => ({ dispatch(showSearch()); }, - onOpenURL (routerHistory) { - dispatch(openURL(routerHistory)); + onOpenURL (q, routerHistory) { + dispatch(openURL(q, routerHistory)); }, onClickSearchResult (q, type) { diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js index 72a732e3b..7b917ac43 100644 --- a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js +++ b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js @@ -74,9 +74,9 @@ describe('emoji', () => { .toEqual('😇'); }); - it('skips the textual presentation VS15 character', () => { + it('does not emojify emojis with textual presentation VS15 character', () => { expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15 - .toEqual('✴'); + .toEqual('✴︎'); }); it('does an simple emoji properly', () => { diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js index 6ae406624..06800d432 100644 --- a/app/javascript/mastodon/features/emoji/emoji.js +++ b/app/javascript/mastodon/features/emoji/emoji.js @@ -20,68 +20,88 @@ const emojiFilename = (filename) => { }; const emojifyTextNode = (node, customEmojis) => { + const VS15 = 0xFE0E; + const VS16 = 0xFE0F; + let str = node.textContent; const fragment = new DocumentFragment(); + let i = 0; for (;;) { - let match, i = 0; + let unicode_emoji; + // Skip to the next potential emoji to replace (either custom emoji or custom emoji :shortcode: if (customEmojis === null) { - while (i < str.length && !(match = trie.search(str.slice(i)))) { + while (i < str.length && !(unicode_emoji = trie.search(str.slice(i)))) { i += str.codePointAt(i) < 65536 ? 1 : 2; } } else { - while (i < str.length && str[i] !== ':' && !(match = trie.search(str.slice(i)))) { + while (i < str.length && str[i] !== ':' && !(unicode_emoji = trie.search(str.slice(i)))) { i += str.codePointAt(i) < 65536 ? 1 : 2; } } - let rend, replacement = null; + // We reached the end of the string, nothing to replace if (i === str.length) { break; - } else if (str[i] === ':') { - if (!(() => { - rend = str.indexOf(':', i + 1) + 1; - if (!rend) return false; // no pair of ':' - const shortname = str.slice(i, rend); - // now got a replacee as ':shortname:' - // if you want additional emoji handler, add statements below which set replacement and return true. - if (shortname in customEmojis) { - const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url; - replacement = document.createElement('img'); - replacement.setAttribute('draggable', 'false'); - replacement.setAttribute('class', 'emojione custom-emoji'); - replacement.setAttribute('alt', shortname); - replacement.setAttribute('title', shortname); - replacement.setAttribute('src', filename); - replacement.setAttribute('data-original', customEmojis[shortname].url); - replacement.setAttribute('data-static', customEmojis[shortname].static_url); - return true; - } - return false; - })()) rend = ++i; - } else { // matched to unicode emoji - const { filename, shortCode } = unicodeMapping[match]; + } + + let rend, replacement = null; + if (str[i] === ':') { // Potentially the start of a custom emoji :shortcode: + rend = str.indexOf(':', i + 1) + 1; + + // no matching ending ':', skip + if (!rend) { + i++; + continue; + } + + const shortcode = str.slice(i, rend); + const custom_emoji = customEmojis[shortcode]; + + // not a recognized shortcode, skip + if (!custom_emoji) { + i++; + continue; + } + + // now got a replacee as ':shortcode:' + // if you want additional emoji handler, add statements below which set replacement and return true. + const filename = autoPlayGif ? custom_emoji.url : custom_emoji.static_url; + replacement = document.createElement('img'); + replacement.setAttribute('draggable', 'false'); + replacement.setAttribute('class', 'emojione custom-emoji'); + replacement.setAttribute('alt', shortcode); + replacement.setAttribute('title', shortcode); + replacement.setAttribute('src', filename); + replacement.setAttribute('data-original', custom_emoji.url); + replacement.setAttribute('data-static', custom_emoji.static_url); + } else { // start of an unicode emoji + rend = i + unicode_emoji.length; + + // If the matched character was followed by VS15 (for selecting text presentation), skip it. + if (str.codePointAt(rend - 1) !== VS16 && str.codePointAt(rend) === VS15) { + i = rend + 1; + continue; + } + + const { filename, shortCode } = unicodeMapping[unicode_emoji]; const title = shortCode ? `:${shortCode}:` : ''; + replacement = document.createElement('img'); replacement.setAttribute('draggable', 'false'); replacement.setAttribute('class', 'emojione'); - replacement.setAttribute('alt', match); + replacement.setAttribute('alt', unicode_emoji); replacement.setAttribute('title', title); replacement.setAttribute('src', `${assetHost}/emoji/${emojiFilename(filename)}.svg`); - rend = i + match.length; - // If the matched character was followed by VS15 (for selecting text presentation), skip it. - if (str.codePointAt(rend) === 65038) { - rend += 1; - } } + // Add the processed-up-to-now string and the emoji replacement fragment.append(document.createTextNode(str.slice(0, i))); - if (replacement) { - fragment.append(replacement); - } + fragment.append(replacement); str = str.slice(rend); + i = 0; } fragment.append(document.createTextNode(str)); diff --git a/app/javascript/mastodon/features/explore/index.jsx b/app/javascript/mastodon/features/explore/index.jsx index 939550d83..35626226e 100644 --- a/app/javascript/mastodon/features/explore/index.jsx +++ b/app/javascript/mastodon/features/explore/index.jsx @@ -71,17 +71,20 @@ class Explore extends React.PureComponent { + + + {signedIn && ( + + + + )} + - {signedIn && ( - - - - )}
diff --git a/app/javascript/mastodon/features/follow_recommendations/components/account.jsx b/app/javascript/mastodon/features/follow_recommendations/components/account.jsx deleted file mode 100644 index 9cb26fe64..000000000 --- a/app/javascript/mastodon/features/follow_recommendations/components/account.jsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; -import { makeGetAccount } from 'mastodon/selectors'; -import Avatar from 'mastodon/components/avatar'; -import DisplayName from 'mastodon/components/display_name'; -import { Link } from 'react-router-dom'; -import IconButton from 'mastodon/components/icon_button'; -import { injectIntl, defineMessages } from 'react-intl'; -import { followAccount, unfollowAccount } from 'mastodon/actions/accounts'; - -const messages = defineMessages({ - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, -}); - -const makeMapStateToProps = () => { - const getAccount = makeGetAccount(); - - const mapStateToProps = (state, props) => ({ - account: getAccount(state, props.id), - }); - - return mapStateToProps; -}; - -const getFirstSentence = str => { - const arr = str.split(/(([.?!]+\s)|[.。?!\n•])/); - - return arr[0]; -}; - -class Account extends ImmutablePureComponent { - - static propTypes = { - account: ImmutablePropTypes.map.isRequired, - intl: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - }; - - handleFollow = () => { - const { account, dispatch } = this.props; - - if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { - dispatch(unfollowAccount(account.get('id'))); - } else { - dispatch(followAccount(account.get('id'))); - } - }; - - render () { - const { account, intl } = this.props; - - let button; - - if (account.getIn(['relationship', 'following'])) { - button = ; - } else { - button = ; - } - - return ( -
-
- -
- - - -
{getFirstSentence(account.get('note_plain'))}
- - -
- {button} -
-
-
- ); - } - -} - -export default connect(makeMapStateToProps)(injectIntl(Account)); diff --git a/app/javascript/mastodon/features/follow_recommendations/index.jsx b/app/javascript/mastodon/features/follow_recommendations/index.jsx deleted file mode 100644 index 7ba34b51f..000000000 --- a/app/javascript/mastodon/features/follow_recommendations/index.jsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { connect } from 'react-redux'; -import { FormattedMessage } from 'react-intl'; -import { fetchSuggestions } from 'mastodon/actions/suggestions'; -import { changeSetting, saveSettings } from 'mastodon/actions/settings'; -import { requestBrowserPermission } from 'mastodon/actions/notifications'; -import { markAsPartial } from 'mastodon/actions/timelines'; -import Column from 'mastodon/features/ui/components/column'; -import Account from './components/account'; -import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg'; -import Button from 'mastodon/components/button'; -import { Helmet } from 'react-helmet'; - -const mapStateToProps = state => ({ - suggestions: state.getIn(['suggestions', 'items']), - isLoading: state.getIn(['suggestions', 'isLoading']), -}); - -class FollowRecommendations extends ImmutablePureComponent { - - static contextTypes = { - router: PropTypes.object.isRequired, - }; - - static propTypes = { - dispatch: PropTypes.func.isRequired, - suggestions: ImmutablePropTypes.list, - isLoading: PropTypes.bool, - }; - - componentDidMount () { - const { dispatch, suggestions } = this.props; - - // Don't re-fetch if we're e.g. navigating backwards to this page, - // since we don't want followed accounts to disappear from the list - - if (suggestions.size === 0) { - dispatch(fetchSuggestions(true)); - } - } - - componentWillUnmount () { - const { dispatch } = this.props; - - // Force the home timeline to be reloaded when the user navigates - // to it; if the user is new, it would've been empty before - - dispatch(markAsPartial('home')); - } - - handleDone = () => { - const { dispatch } = this.props; - const { router } = this.context; - - dispatch(requestBrowserPermission((permission) => { - if (permission === 'granted') { - dispatch(changeSetting(['notifications', 'alerts', 'follow'], true)); - dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true)); - dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true)); - dispatch(changeSetting(['notifications', 'alerts', 'mention'], true)); - dispatch(changeSetting(['notifications', 'alerts', 'poll'], true)); - dispatch(changeSetting(['notifications', 'alerts', 'status'], true)); - dispatch(saveSettings()); - } - })); - - router.history.push('/home'); - }; - - render () { - const { suggestions, isLoading } = this.props; - - return ( - -
-
- - - - -

-

-
- - {!isLoading && ( - -
- {suggestions.size > 0 ? suggestions.map(suggestion => ( - - )) : ( -
- -
- )} -
- -
- - -
-
- )} -
- - - - -
- ); - } - -} - -export default connect(mapStateToProps)(FollowRecommendations); diff --git a/app/javascript/mastodon/features/notifications/components/report.jsx b/app/javascript/mastodon/features/notifications/components/report.jsx index 4663b2359..a9decdc8e 100644 --- a/app/javascript/mastodon/features/notifications/components/report.jsx +++ b/app/javascript/mastodon/features/notifications/components/report.jsx @@ -45,7 +45,7 @@ class Report extends ImmutablePureComponent {
- · + ·
{intl.formatMessage(messages[report.get('category')])}
diff --git a/app/javascript/mastodon/features/onboarding/components/arrow_small_right.jsx b/app/javascript/mastodon/features/onboarding/components/arrow_small_right.jsx new file mode 100644 index 000000000..40e166f6d --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/components/arrow_small_right.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +const ArrowSmallRight = () => ( + + + +); + +export default ArrowSmallRight; \ No newline at end of file diff --git a/app/javascript/mastodon/features/onboarding/components/progress_indicator.jsx b/app/javascript/mastodon/features/onboarding/components/progress_indicator.jsx new file mode 100644 index 000000000..97134c0c9 --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/components/progress_indicator.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Check from 'mastodon/components/check'; +import classNames from 'classnames'; + +const ProgressIndicator = ({ steps, completed }) => ( +
+ {(new Array(steps)).fill().map((_, i) => ( + + {i > 0 &&
i })} />} + +
i })}> + {completed > i && } +
+ + ))} +
+); + +ProgressIndicator.propTypes = { + steps: PropTypes.number.isRequired, + completed: PropTypes.number, +}; + +export default ProgressIndicator; \ No newline at end of file diff --git a/app/javascript/mastodon/features/onboarding/components/step.jsx b/app/javascript/mastodon/features/onboarding/components/step.jsx new file mode 100644 index 000000000..6f376e5d5 --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/components/step.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'mastodon/components/icon'; +import Check from 'mastodon/components/check'; + +const Step = ({ label, description, icon, completed, onClick, href }) => { + const content = ( + <> +
+ +
+ +
+
{label}
+

{description}

+
+ + {completed && ( +
+ +
+ )} + + ); + + if (href) { + return ( +
+ {content} + + ); + } + + return ( + + ); +}; + +Step.propTypes = { + label: PropTypes.node, + description: PropTypes.node, + icon: PropTypes.string, + completed: PropTypes.bool, + href: PropTypes.string, + onClick: PropTypes.func, +}; + +export default Step; \ No newline at end of file diff --git a/app/javascript/mastodon/features/onboarding/follows.jsx b/app/javascript/mastodon/features/onboarding/follows.jsx new file mode 100644 index 000000000..7cccdefb3 --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/follows.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import Column from 'mastodon/components/column'; +import ColumnBackButton from 'mastodon/components/column_back_button'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { fetchSuggestions } from 'mastodon/actions/suggestions'; +import { markAsPartial } from 'mastodon/actions/timelines'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Account from 'mastodon/containers/account_container'; +import EmptyAccount from 'mastodon/components/account'; +import { FormattedMessage, FormattedHTMLMessage } from 'react-intl'; +import { makeGetAccount } from 'mastodon/selectors'; +import { me } from 'mastodon/initial_state'; +import ProgressIndicator from './components/progress_indicator'; + +const mapStateToProps = () => { + const getAccount = makeGetAccount(); + + return state => ({ + account: getAccount(state, me), + suggestions: state.getIn(['suggestions', 'items']), + isLoading: state.getIn(['suggestions', 'isLoading']), + }); +}; + +class Follows extends React.PureComponent { + + static propTypes = { + onBack: PropTypes.func, + dispatch: PropTypes.func.isRequired, + suggestions: ImmutablePropTypes.list, + account: ImmutablePropTypes.map, + isLoading: PropTypes.bool, + }; + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchSuggestions(true)); + } + + componentWillUnmount () { + const { dispatch } = this.props; + dispatch(markAsPartial('home')); + } + + render () { + const { onBack, isLoading, suggestions, account } = this.props; + + let loadedContent; + + if (isLoading) { + loadedContent = (new Array(8)).fill().map((_, i) => ); + } else if (suggestions.isEmpty()) { + loadedContent =
; + } else { + loadedContent = suggestions.map(suggestion => ); + } + + return ( + + + +
+
+

+

+
+ + + +
+ {loadedContent} +
+ +

+ +
+ +
+
+
+ ); + } + +} + +export default connect(mapStateToProps)(Follows); \ No newline at end of file diff --git a/app/javascript/mastodon/features/onboarding/index.jsx b/app/javascript/mastodon/features/onboarding/index.jsx new file mode 100644 index 000000000..388734055 --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/index.jsx @@ -0,0 +1,145 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import { focusCompose } from 'mastodon/actions/compose'; +import Column from 'mastodon/features/ui/components/column'; +import { Helmet } from 'react-helmet'; +import illustration from 'mastodon/../images/elephant_ui_conversation.svg'; +import { Link } from 'react-router-dom'; +import { me } from 'mastodon/initial_state'; +import { makeGetAccount } from 'mastodon/selectors'; +import { closeOnboarding } from 'mastodon/actions/onboarding'; +import { fetchAccount } from 'mastodon/actions/accounts'; +import Follows from './follows'; +import Share from './share'; +import Step from './components/step'; +import ArrowSmallRight from './components/arrow_small_right'; +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; +import { debounce } from 'lodash'; + +const messages = defineMessages({ + template: { id: 'onboarding.compose.template', defaultMessage: 'Hello #Mastodon!' }, +}); + +const mapStateToProps = () => { + const getAccount = makeGetAccount(); + + return state => ({ + account: getAccount(state, me), + }); +}; + +class Onboarding extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object.isRequired, + }; + + static propTypes = { + dispatch: PropTypes.func.isRequired, + account: ImmutablePropTypes.map, + }; + + state = { + step: null, + profileClicked: false, + shareClicked: false, + }; + + handleClose = () => { + const { dispatch } = this.props; + const { router } = this.context; + + dispatch(closeOnboarding()); + router.history.push('/home'); + }; + + handleProfileClick = () => { + this.setState({ profileClicked: true }); + }; + + handleFollowClick = () => { + this.setState({ step: 'follows' }); + }; + + handleComposeClick = () => { + const { dispatch, intl } = this.props; + const { router } = this.context; + + dispatch(focusCompose(router.history, intl.formatMessage(messages.template))); + }; + + handleShareClick = () => { + this.setState({ step: 'share', shareClicked: true }); + }; + + handleBackClick = () => { + this.setState({ step: null }); + }; + + handleWindowFocus = debounce(() => { + const { dispatch, account } = this.props; + dispatch(fetchAccount(account.get('id'))); + }, 1000, { trailing: true }); + + componentDidMount () { + window.addEventListener('focus', this.handleWindowFocus, false); + } + + componentWillUnmount () { + window.removeEventListener('focus', this.handleWindowFocus); + } + + render () { + const { account } = this.props; + const { step, shareClicked } = this.state; + + switch(step) { + case 'follows': + return ; + case 'share': + return ; + } + + return ( + +
+
+ +

+

+
+ +
+ 0 && account.get('note').length > 0)} icon='address-book-o' label={} description={} /> + = 7} icon='user-plus' label={} description={} /> + = 1} icon='pencil-square-o' label={} description={} /> + } description={} /> +
+ +

+ +
+ + + + +
+ +
+ +
+
+ + + + +
+ ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Onboarding)); diff --git a/app/javascript/mastodon/features/onboarding/share.jsx b/app/javascript/mastodon/features/onboarding/share.jsx new file mode 100644 index 000000000..9555a3a43 --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/share.jsx @@ -0,0 +1,193 @@ +import React from 'react'; +import Column from 'mastodon/components/column'; +import ColumnBackButton from 'mastodon/components/column_back_button'; +import PropTypes from 'prop-types'; +import { me, domain } from 'mastodon/initial_state'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage, FormattedHTMLMessage } from 'react-intl'; +import classNames from 'classnames'; +import Icon from 'mastodon/components/icon'; +import ArrowSmallRight from './components/arrow_small_right'; +import { Link } from 'react-router-dom'; +import SwipeableViews from 'react-swipeable-views'; + +const messages = defineMessages({ + shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' }, +}); + +const mapStateToProps = state => ({ + account: state.getIn(['accounts', me]), +}); + +class CopyPasteText extends React.PureComponent { + + static propTypes = { + value: PropTypes.string, + }; + + state = { + copied: false, + focused: false, + }; + + setRef = c => { + this.input = c; + }; + + handleInputClick = () => { + this.setState({ copied: false }); + this.input.focus(); + this.input.select(); + this.input.setSelectionRange(0, this.props.value.length); + }; + + handleButtonClick = e => { + e.stopPropagation(); + + const { value } = this.props; + navigator.clipboard.writeText(value); + this.input.blur(); + this.setState({ copied: true }); + this.timeout = setTimeout(() => this.setState({ copied: false }), 700); + }; + + handleFocus = () => { + this.setState({ focused: true }); + }; + + handleBlur = () => { + this.setState({ focused: false }); + }; + + componentWillUnmount () { + if (this.timeout) clearTimeout(this.timeout); + } + + render () { + const { value } = this.props; + const { copied, focused } = this.state; + + return ( +
+