diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 137bebc599..40dc72c12d 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -70,7 +70,7 @@ services: hard: -1 libretranslate: - image: libretranslate/libretranslate:v1.4.1 + image: libretranslate/libretranslate:v1.5.2 restart: unless-stopped volumes: - lt-data:/home/libretranslate/.local diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml index 00a5c46bdc..07fd4d08d3 100644 --- a/.github/actions/setup-javascript/action.yml +++ b/.github/actions/setup-javascript/action.yml @@ -9,7 +9,7 @@ runs: using: 'composite' steps: - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000000..5532c49618 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,13 @@ +coverage: + status: + project: + default: + # Github status check is not blocking + informational: true + patch: + default: + # Github status check is not blocking + informational: true +comment: + # Only write a comment in PR if there are changes + require_changes: true diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 77b64fdd3f..895dbfbad2 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -22,6 +22,7 @@ 'react-hotkeys', // Requires code changes // Requires Webpacker upgrade or replacement + '@svgr/webpack', '@types/webpack', 'babel-loader', 'compression-webpack-plugin', diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 07fd25fb1b..ae25648a0b 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -94,7 +94,7 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres - DISABLE_SIMPLECOV: true + DISABLE_SIMPLECOV: ${{ matrix.ruby-version != '.ruby-version' }} RAILS_ENV: test ALLOW_NOPAM: true PAM_ENABLED: true @@ -137,6 +137,12 @@ jobs: - run: bin/rspec + - name: Upload coverage reports to Codecov + if: matrix.ruby-version == '.ruby-version' + uses: codecov/codecov-action@v3 + with: + files: coverage/lcov/mastodon.lcov + test-e2e: name: End to End testing runs-on: ubuntu-latest @@ -221,7 +227,7 @@ jobs: path: tmp/screenshots/ test-search: - name: Testing search + name: Elastic Search integration testing runs-on: ubuntu-latest needs: @@ -308,7 +314,7 @@ jobs: - name: Load database schema run: './bin/rails db:create db:schema:load db:seed' - - run: bundle exec rake spec:search + - run: bin/rspec --tag search - name: Archive logs uses: actions/upload-artifact@v3 diff --git a/.simplecov b/.simplecov new file mode 100644 index 0000000000..fbd0207bec --- /dev/null +++ b/.simplecov @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +if ENV['CI'] + require 'simplecov-lcov' + SimpleCov::Formatter::LcovFormatter.config.report_with_single_file = true + SimpleCov.formatter = SimpleCov::Formatter::LcovFormatter +else + SimpleCov.formatter = SimpleCov::Formatter::HTMLFormatter +end + +SimpleCov.start 'rails' do + enable_coverage :branch + + add_filter 'lib/linter' + + add_group 'Libraries', 'lib' + add_group 'Policies', 'app/policies' + add_group 'Presenters', 'app/presenters' + add_group 'Serializers', 'app/serializers' + add_group 'Services', 'app/services' + add_group 'Validators', 'app/validators' +end diff --git a/Dockerfile b/Dockerfile index 2b23ea6e48..7e032073b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,7 @@ RUN apt-get update && \ corepack enable COPY Gemfile* package.json yarn.lock .yarnrc.yml /opt/mastodon/ +COPY streaming/package.json /opt/mastodon/streaming/ COPY .yarn /opt/mastodon/.yarn RUN bundle install -j"$(nproc)" diff --git a/Gemfile b/Gemfile index 47508175b6..d162a78399 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,9 @@ gem 'sprockets', '~> 3.7.2' gem 'thor', '~> 1.2' gem 'rack', '~> 2.2.7' +# For why irb is in the Gemfile, see: https://ruby.social/@st0012/111444685161478182 +gem 'irb', '~> 1.8' + gem 'haml-rails', '~>2.0' gem 'pg', '~> 1.5' gem 'pghero' @@ -109,6 +112,9 @@ group :test do # RSpec progress bar formatter gem 'fuubar', '~> 2.5' + # RSpec helpers for email specs + gem 'email_spec' + # Extra RSpec extenion methods and helpers for sidekiq gem 'rspec-sidekiq', '~> 4.0' @@ -139,6 +145,7 @@ group :test do # Coverage formatter for RSpec test if DISABLE_SIMPLECOV is false gem 'simplecov', '~> 0.22', require: false + gem 'simplecov-lcov', '~> 0.8', require: false # Stub web requests for specs gem 'webmock', '~> 3.18' @@ -175,6 +182,9 @@ group :development do end group :development, :test do + # Interactive Debugging tools + gem 'debug', '~> 1.8' + # Profiling tools gem 'memory_profiler', require: false gem 'ruby-prof', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 8d9af4e970..72b60e05b1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -130,21 +130,21 @@ GEM encryptor (~> 3.0.0) attr_required (1.0.1) awrence (1.2.1) - aws-eventstream (1.2.0) - aws-partitions (1.828.0) - aws-sdk-core (3.183.1) + aws-eventstream (1.3.0) + aws-partitions (1.857.0) + aws-sdk-core (3.188.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.71.0) - aws-sdk-core (~> 3, >= 3.177.0) + aws-sdk-kms (1.73.0) + aws-sdk-core (~> 3, >= 3.188.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.136.0) - aws-sdk-core (~> 3, >= 3.181.0) + aws-sdk-s3 (1.140.0) + aws-sdk-core (~> 3, >= 3.188.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.6) - aws-sigv4 (1.6.0) + aws-sigv4 (1.7.0) aws-eventstream (~> 1, >= 1.0.2) azure-storage-blob (2.0.3) azure-storage-common (~> 2.0) @@ -154,7 +154,7 @@ GEM faraday_middleware (~> 1.0, >= 1.0.0.rc1) net-http-persistent (~> 4.0) nokogiri (~> 1, >= 1.10.8) - base64 (0.1.1) + base64 (0.2.0) bcp47_spec (0.2.1) bcrypt (3.1.19) better_errors (2.10.1) @@ -220,6 +220,9 @@ GEM database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) date (3.3.4) + debug (1.8.0) + irb (>= 1.5.0) + reline (>= 0.3.1) debug_inspector (1.1.0) devise (4.9.3) bcrypt (~> 3.0) @@ -242,13 +245,13 @@ GEM docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) - doorkeeper (5.6.6) + doorkeeper (5.6.7) railties (>= 5) dotenv (2.8.1) dotenv-rails (2.8.1) dotenv (= 2.8.1) railties (>= 3.2) - drb (2.1.1) + drb (2.2.0) ruby2_keywords ed25519 (1.3.0) elasticsearch (7.13.3) @@ -260,12 +263,16 @@ GEM elasticsearch-transport (7.13.3) faraday (~> 1) multi_json + email_spec (2.2.2) + htmlentities (~> 4.3.3) + launchy (~> 2.1) + mail (~> 2.7) encryptor (3.0.0) erubi (1.12.0) et-orbi (1.2.7) tzinfo excon (0.104.0) - fabrication (2.30.0) + fabrication (2.31.0) faker (3.2.2) i18n (>= 1.8.11, < 2) faraday (1.10.3) @@ -370,7 +377,7 @@ GEM terminal-table (>= 1.5.1) idn-ruby (0.1.5) io-console (0.6.0) - irb (1.8.3) + irb (1.9.1) rdoc reline (>= 0.3.8) jmespath (1.6.2) @@ -432,7 +439,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.21.4) + loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -458,7 +465,7 @@ GEM msgpack (1.7.2) multi_json (1.15.0) multipart-post (2.3.0) - mutex_m (0.1.2) + mutex_m (0.2.0) net-http (0.4.0) uri net-http-persistent (4.0.2) @@ -474,7 +481,7 @@ GEM net-smtp (0.4.0) net-protocol nio4r (2.5.9) - nokogiri (1.15.4) + nokogiri (1.15.5) mini_portile2 (~> 2.8.2) racc (~> 1.4) oj (3.16.1) @@ -529,7 +536,7 @@ GEM private_address_check (0.5.0) psych (5.1.1.1) stringio - public_suffix (5.0.3) + public_suffix (5.0.4) puma (6.4.0) nio4r (~> 2.0) pundit (2.3.1) @@ -595,13 +602,13 @@ GEM thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.0.6) + rake (13.1.0) rdf (3.3.1) bcp47_spec (~> 0.2) link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.6.1) rdf (~> 3.2) - rdoc (6.5.0) + rdoc (6.6.0) psych (>= 4.0.0) redcarpet (3.6.0) redis (4.8.1) @@ -610,7 +617,7 @@ GEM redlock (1.3.2) redis (>= 3.0.0, < 6.0) regexp_parser (2.8.2) - reline (0.3.9) + reline (0.4.0) io-console (~> 0.5) request_store (1.5.1) rack (>= 1.4) @@ -635,7 +642,7 @@ GEM rspec-mocks (3.12.6) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) - rspec-rails (6.0.3) + rspec-rails (6.1.0) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) @@ -669,10 +676,11 @@ GEM rubocop-performance (1.19.1) rubocop (>= 1.7.0, < 2.0) rubocop-ast (>= 0.4.0) - rubocop-rails (2.22.1) + rubocop-rails (2.22.2) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) rubocop-rspec (2.25.0) rubocop (~> 1.40) rubocop-capybara (~> 2.17) @@ -725,6 +733,7 @@ GEM simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) + simplecov-lcov (0.8.0) simplecov_json_formatter (0.1.4) smart_properties (1.17.0) sprockets (3.7.2) @@ -751,7 +760,7 @@ GEM unicode-display_width (>= 1.1.1, < 3) terrapin (0.6.0) climate_control (>= 0.0.3, < 1.0) - test-prof (1.2.3) + test-prof (1.3.0) thor (1.3.0) tilt (2.3.0) timeout (0.4.1) @@ -845,6 +854,7 @@ DEPENDENCIES concurrent-ruby connection_pool database_cleaner-active_record + debug (~> 1.8) devise (~> 4.9) devise-two-factor (~> 4.1) devise_pam_authenticatable2 (~> 9.2) @@ -852,6 +862,7 @@ DEPENDENCIES doorkeeper (~> 5.6) dotenv-rails (~> 2.8) ed25519 (~> 1.3) + email_spec fabrication (~> 2.30) faker (~> 3.2) fast_blank (~> 1.0) @@ -869,6 +880,7 @@ DEPENDENCIES httplog (~> 1.6.2) i18n-tasks (~> 1.0) idn-ruby + irb (~> 1.8) json-ld json-ld-preloaded (~> 3.2) json-schema (~> 4.0) @@ -936,6 +948,7 @@ DEPENDENCIES simple-navigation (~> 4.4) simple_form (~> 5.2) simplecov (~> 0.22) + simplecov-lcov (~> 0.8) sprockets (~> 3.7.2) sprockets-rails (~> 3.4) stackprof diff --git a/Procfile.dev b/Procfile.dev index fbb2c2de23..f81333b04c 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,4 +1,4 @@ web: env PORT=3000 RAILS_ENV=development bundle exec puma -C config/puma.rb sidekiq: env PORT=3000 RAILS_ENV=development bundle exec sidekiq -stream: env PORT=4000 yarn run start +stream: env PORT=4000 yarn workspace @mastodon/streaming start webpack: bin/webpack-dev-server diff --git a/app/chewy/accounts_index.rb b/app/chewy/accounts_index.rb index 00db257ac7..59f2f991f2 100644 --- a/app/chewy/accounts_index.rb +++ b/app/chewy/accounts_index.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class AccountsIndex < Chewy::Index + include DatetimeClampingConcern + settings index: index_preset(refresh_interval: '30s'), analysis: { filter: { english_stop: { @@ -60,7 +62,7 @@ class AccountsIndex < Chewy::Index field(:following_count, type: 'long') field(:followers_count, type: 'long') field(:properties, type: 'keyword', value: ->(account) { account.searchable_properties }) - field(:last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }) + field(:last_status_at, type: 'date', value: ->(account) { clamp_date(account.last_status_at || account.created_at) }) field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } field(:text, type: 'text', analyzer: 'verbatim', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' } diff --git a/app/chewy/concerns/datetime_clamping_concern.rb b/app/chewy/concerns/datetime_clamping_concern.rb new file mode 100644 index 0000000000..7f176b6e54 --- /dev/null +++ b/app/chewy/concerns/datetime_clamping_concern.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module DatetimeClampingConcern + extend ActiveSupport::Concern + + MIN_ISO8601_DATETIME = '0000-01-01T00:00:00Z'.to_datetime.freeze + MAX_ISO8601_DATETIME = '9999-12-31T23:59:59Z'.to_datetime.freeze + + class_methods do + def clamp_date(datetime) + datetime.clamp(MIN_ISO8601_DATETIME, MAX_ISO8601_DATETIME) + end + end +end diff --git a/app/chewy/public_statuses_index.rb b/app/chewy/public_statuses_index.rb index b5f0be5e5c..09a4dfc093 100644 --- a/app/chewy/public_statuses_index.rb +++ b/app/chewy/public_statuses_index.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class PublicStatusesIndex < Chewy::Index + include DatetimeClampingConcern + settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: { filter: { english_stop: { @@ -62,6 +64,6 @@ class PublicStatusesIndex < Chewy::Index field(:tags, type: 'text', analyzer: 'hashtag', value: ->(status) { status.tags.map(&:display_name) }) field(:language, type: 'keyword') field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties }) - field(:created_at, type: 'date') + field(:created_at, type: 'date', value: ->(status) { clamp_date(status.created_at) }) end end diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb index e315a2030f..e739ccecb4 100644 --- a/app/chewy/statuses_index.rb +++ b/app/chewy/statuses_index.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class StatusesIndex < Chewy::Index + include DatetimeClampingConcern + settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: { filter: { english_stop: { @@ -60,6 +62,6 @@ class StatusesIndex < Chewy::Index field(:searchable_by, type: 'long', value: ->(status) { status.searchable_by }) field(:language, type: 'keyword') field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties }) - field(:created_at, type: 'date') + field(:created_at, type: 'date', value: ->(status) { clamp_date(status.created_at) }) end end diff --git a/app/chewy/tags_index.rb b/app/chewy/tags_index.rb index 5b6349a964..c99218a47f 100644 --- a/app/chewy/tags_index.rb +++ b/app/chewy/tags_index.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class TagsIndex < Chewy::Index + include DatetimeClampingConcern + settings index: index_preset(refresh_interval: '30s'), analysis: { analyzer: { content: { @@ -42,6 +44,6 @@ class TagsIndex < Chewy::Index field(:name, type: 'text', analyzer: 'content', value: :display_name) { field(:edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content') } field(:reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }) field(:usage, type: 'long', value: ->(tag, crutches) { tag.history.aggregate(crutches.time_period).accounts }) - field(:last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at }) + field(:last_status_at, type: 'date', value: ->(tag) { clamp_date(tag.last_status_at || tag.created_at) }) end end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 32fc378790..f3943bbb6b 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -18,8 +18,6 @@ class AccountsController < ApplicationController respond_to do |format| format.html do 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 format.rss do @@ -84,29 +82,21 @@ class AccountsController < ApplicationController short_account_url(@account, format: 'rss') end end + helper_method :rss_url def media_requested? - request.path.split('.').first.end_with?('/media') && !tag_requested? + path_without_format.end_with?('/media') && !tag_requested? end def replies_requested? - request.path.split('.').first.end_with?('/with_replies') && !tag_requested? + path_without_format.end_with?('/with_replies') && !tag_requested? end def tag_requested? - request.path.split('.').first.end_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize) + path_without_format.end_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize) end - def cached_filtered_status_page - cache_collection_paginated_by_id( - filtered_statuses, - Status, - PAGE_SIZE, - params_slice(:max_id, :min_id, :since_id) - ) - end - - def params_slice(*keys) - params.slice(*keys).permit(*keys) + def path_without_format + request.path.split('.').first end end diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index 5712dea888..e53b22dca3 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -32,7 +32,7 @@ module Admin private def batched_ordered_status_edits - @status.edits.reorder(nil).includes(:account, status: [:account]).find_each(order: :asc) + @status.edits.includes(:account, status: [:account]).find_each(order: :asc) end helper_method :batched_ordered_status_edits diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 76ba758245..8f31336b9f 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -16,6 +16,8 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController current_user.update(user_params) if user_params ActivityPub::UpdateDistributionWorker.perform_async(@account.id) render json: @account, serializer: REST::CredentialAccountSerializer + rescue ActiveRecord::RecordInvalid => e + render json: ValidationErrorFormatter.new(e).as_json, status: 422 end private diff --git a/app/controllers/api/v1/accounts/familiar_followers_controller.rb b/app/controllers/api/v1/accounts/familiar_followers_controller.rb index b0bd8018a2..a49eb2eb27 100644 --- a/app/controllers/api/v1/accounts/familiar_followers_controller.rb +++ b/app/controllers/api/v1/accounts/familiar_followers_controller.rb @@ -12,7 +12,7 @@ class Api::V1::Accounts::FamiliarFollowersController < Api::BaseController private def set_accounts - @accounts = Account.without_suspended.where(id: account_ids).select('id, hide_collections').index_by(&:id).values_at(*account_ids).compact + @accounts = Account.without_suspended.where(id: account_ids).select('id, hide_collections') end def familiar_followers diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb index 320084efb5..e5ae5b007b 100644 --- a/app/controllers/api/v1/accounts/relationships_controller.rb +++ b/app/controllers/api/v1/accounts/relationships_controller.rb @@ -5,11 +5,8 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController before_action :require_user! def index - scope = Account.where(id: account_ids).select('id') - scope.merge!(Account.without_suspended) unless truthy_param?(:with_suspended) - # .where doesn't guarantee that our results are in the same order - # we requested them, so return the "right" order to the requestor. - @accounts = scope.index_by(&:id).values_at(*account_ids).compact + @accounts = Account.where(id: account_ids).select('id') + @accounts.merge!(Account.without_suspended) unless truthy_param?(:with_suspended) render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/controllers/api/v1/instances/activity_controller.rb b/app/controllers/api/v1/instances/activity_controller.rb index 4c17bd79c2..06e4fd8b8f 100644 --- a/app/controllers/api/v1/instances/activity_controller.rb +++ b/app/controllers/api/v1/instances/activity_controller.rb @@ -3,6 +3,8 @@ class Api::V1::Instances::ActivityController < Api::V1::Instances::BaseController before_action :require_enabled_api! + WEEKS_OF_ACTIVITY = 12 + def show cache_even_if_authenticated! render_with_cache json: :activity, expires_in: 1.day @@ -11,23 +13,40 @@ class Api::V1::Instances::ActivityController < Api::V1::Instances::BaseControlle private def activity - statuses_tracker = ActivityTracker.new('activity:statuses:local', :basic) - logins_tracker = ActivityTracker.new('activity:logins', :unique) - registrations_tracker = ActivityTracker.new('activity:accounts:local', :basic) - - (0...12).map do |i| - start_of_week = i.weeks.ago - end_of_week = start_of_week + 6.days - - { - week: start_of_week.to_i.to_s, - statuses: statuses_tracker.sum(start_of_week, end_of_week).to_s, - logins: logins_tracker.sum(start_of_week, end_of_week).to_s, - registrations: registrations_tracker.sum(start_of_week, end_of_week).to_s, - } + activity_weeks.map do |weeks_ago| + activity_json(*week_edge_days(weeks_ago)) end end + def activity_json(start_of_week, end_of_week) + { + week: start_of_week.to_i.to_s, + statuses: statuses_tracker.sum(start_of_week, end_of_week).to_s, + logins: logins_tracker.sum(start_of_week, end_of_week).to_s, + registrations: registrations_tracker.sum(start_of_week, end_of_week).to_s, + } + end + + def activity_weeks + 0...WEEKS_OF_ACTIVITY + end + + def week_edge_days(num) + [num.weeks.ago, num.weeks.ago + 6.days] + end + + def statuses_tracker + ActivityTracker.new('activity:statuses:local', :basic) + end + + def logins_tracker + ActivityTracker.new('activity:logins', :unique) + end + + def registrations_tracker + ActivityTracker.new('activity:accounts:local', :basic) + end + def require_enabled_api! head 404 unless Setting.activity_api_enabled && !limited_federation_mode? end diff --git a/app/controllers/api/v1/instances/domain_blocks_controller.rb b/app/controllers/api/v1/instances/domain_blocks_controller.rb index 8fb90305ad..7ec94312f4 100644 --- a/app/controllers/api/v1/instances/domain_blocks_controller.rb +++ b/app/controllers/api/v1/instances/domain_blocks_controller.rb @@ -19,7 +19,19 @@ class Api::V1::Instances::DomainBlocksController < Api::V1::Instances::BaseContr private def require_enabled_api! - head 404 unless Setting.show_domain_blocks == 'all' || (Setting.show_domain_blocks == 'users' && user_signed_in?) + head 404 unless api_enabled? + end + + def api_enabled? + show_domain_blocks_for_all? || show_domain_blocks_to_user? + end + + def show_domain_blocks_for_all? + Setting.show_domain_blocks == 'all' + end + + def show_domain_blocks_to_user? + Setting.show_domain_blocks == 'users' && user_signed_in? end def set_domain_blocks diff --git a/app/controllers/api/v1/statuses/histories_controller.rb b/app/controllers/api/v1/statuses/histories_controller.rb index dcb21ef043..e381ea2c67 100644 --- a/app/controllers/api/v1/statuses/histories_controller.rb +++ b/app/controllers/api/v1/statuses/histories_controller.rb @@ -11,6 +11,6 @@ class Api::V1::Statuses::HistoriesController < Api::V1::Statuses::BaseController private def status_edits - @status.edits.includes(:account, status: [:account]).to_a.presence || [@status.build_snapshot(at_time: @status.edited_at || @status.created_at)] + @status.edits.ordered.includes(:account, status: [:account]).to_a.presence || [@status.build_snapshot(at_time: @status.edited_at || @status.created_at)] end end diff --git a/app/controllers/api/v1/timelines/base_controller.rb b/app/controllers/api/v1/timelines/base_controller.rb new file mode 100644 index 0000000000..173e173cc9 --- /dev/null +++ b/app/controllers/api/v1/timelines/base_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::BaseController < Api::BaseController + after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + + private + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id + end + + def next_path_params + permitted_params.merge(max_id: pagination_max_id) + end + + def prev_path_params + permitted_params.merge(min_id: pagination_since_id) + end + + def permitted_params + params + .slice(*self.class::PERMITTED_PARAMS) + .permit(*self.class::PERMITTED_PARAMS) + end +end diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index 83b8cb4c66..36fdbea647 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true -class Api::V1::Timelines::HomeController < Api::BaseController +class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show] before_action :require_user!, only: [:show] - after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + + PERMITTED_PARAMS = %i(local limit).freeze def show with_read_replica do @@ -40,27 +41,11 @@ class Api::V1::Timelines::HomeController < Api::BaseController HomeFeed.new(current_account) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - - def pagination_params(core_params) - params.slice(:local, :limit).permit(:local, :limit).merge(core_params) - end - def next_path - api_v1_timelines_home_url pagination_params(max_id: pagination_max_id) + api_v1_timelines_home_url next_path_params end def prev_path - api_v1_timelines_home_url pagination_params(min_id: pagination_since_id) - end - - def pagination_max_id - @statuses.last.id - end - - def pagination_since_id - @statuses.first.id + api_v1_timelines_home_url prev_path_params end end diff --git a/app/controllers/api/v1/timelines/list_controller.rb b/app/controllers/api/v1/timelines/list_controller.rb index a15eae468d..14b884ecd9 100644 --- a/app/controllers/api/v1/timelines/list_controller.rb +++ b/app/controllers/api/v1/timelines/list_controller.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -class Api::V1::Timelines::ListController < Api::BaseController +class Api::V1::Timelines::ListController < Api::V1::Timelines::BaseController before_action -> { doorkeeper_authorize! :read, :'read:lists' } before_action :require_user! before_action :set_list before_action :set_statuses - after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + PERMITTED_PARAMS = %i(limit).freeze def show render json: @statuses, @@ -41,27 +41,11 @@ class Api::V1::Timelines::ListController < Api::BaseController ListFeed.new(@list) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - - def pagination_params(core_params) - params.slice(:limit).permit(:limit).merge(core_params) - end - def next_path - api_v1_timelines_list_url params[:id], pagination_params(max_id: pagination_max_id) + api_v1_timelines_list_url params[:id], next_path_params end def prev_path - api_v1_timelines_list_url params[:id], pagination_params(min_id: pagination_since_id) - end - - def pagination_max_id - @statuses.last.id - end - - def pagination_since_id - @statuses.first.id + api_v1_timelines_list_url params[:id], prev_path_params end end diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index 6af504ff63..5bc8de8334 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true -class Api::V1::Timelines::PublicController < Api::BaseController +class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController before_action :require_user!, only: [:show], if: :require_auth? - after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + + PERMITTED_PARAMS = %i(local remote limit only_media allow_local_only).freeze def show cache_if_unauthenticated! @@ -45,27 +46,11 @@ class Api::V1::Timelines::PublicController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - - def pagination_params(core_params) - params.slice(:local, :remote, :limit, :only_media, :allow_local_only).permit(:local, :remote, :limit, :only_media, :allow_local_only).merge(core_params) - end - def next_path - api_v1_timelines_public_url pagination_params(max_id: pagination_max_id) + api_v1_timelines_public_url next_path_params end def prev_path - api_v1_timelines_public_url pagination_params(min_id: pagination_since_id) - end - - def pagination_max_id - @statuses.last.id - end - - def pagination_since_id - @statuses.first.id + api_v1_timelines_public_url prev_path_params end end diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index a79d65c124..4ba439dbb2 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true -class Api::V1::Timelines::TagController < Api::BaseController +class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :show, if: :require_auth? before_action :load_tag - after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + + PERMITTED_PARAMS = %i(local limit only_media).freeze def show cache_if_unauthenticated! @@ -51,27 +52,11 @@ class Api::V1::Timelines::TagController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - - def pagination_params(core_params) - params.slice(:local, :limit, :only_media).permit(:local, :limit, :only_media).merge(core_params) - end - def next_path - api_v1_timelines_tag_url params[:id], pagination_params(max_id: pagination_max_id) + api_v1_timelines_tag_url params[:id], next_path_params end def prev_path - api_v1_timelines_tag_url params[:id], pagination_params(min_id: pagination_since_id) - end - - def pagination_max_id - @statuses.last.id - end - - def pagination_since_id - @statuses.first.id + api_v1_timelines_tag_url params[:id], prev_path_params end end diff --git a/app/controllers/api/v2/media_controller.rb b/app/controllers/api/v2/media_controller.rb index 72bc694421..36c15165da 100644 --- a/app/controllers/api/v2/media_controller.rb +++ b/app/controllers/api/v2/media_controller.rb @@ -2,12 +2,22 @@ class Api::V2::MediaController < Api::V1::MediaController def create - @media_attachment = current_account.media_attachments.create!({ delay_processing: true }.merge(media_attachment_params)) - render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: @media_attachment.not_processed? ? 202 : 200 + @media_attachment = current_account.media_attachments.create!(media_and_delay_params) + render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_from_media_processing rescue Paperclip::Errors::NotIdentifiedByImageMagickError render json: file_type_error, status: 422 rescue Paperclip::Error => e Rails.logger.error "#{e.class}: #{e.message}" render json: processing_error, status: 500 end + + private + + def media_and_delay_params + { delay_processing: true }.merge(media_attachment_params) + end + + def status_from_media_processing + @media_attachment.not_processed? ? 202 : 200 + end end diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index 5167928e93..167d16fc4d 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -3,37 +3,13 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController before_action :require_user! before_action :set_push_subscription, only: :update + before_action :destroy_previous_subscriptions, only: :create, if: :prior_subscriptions? + after_action :update_session_with_subscription, only: :create def create - active_session = current_session + @push_subscription = ::Web::PushSubscription.create!(web_push_subscription_params) - unless active_session.web_push_subscription.nil? - active_session.web_push_subscription.destroy! - active_session.update!(web_push_subscription: nil) - end - - # Mobile devices do not support regular notifications, so we enable push notifications by default - alerts_enabled = active_session.detection.device.mobile? || active_session.detection.device.tablet? - - data = { - policy: 'all', - alerts: Notification::TYPES.index_with { alerts_enabled }, - } - - data.deep_merge!(data_params) if params[:data] - - push_subscription = ::Web::PushSubscription.create!( - endpoint: subscription_params[:endpoint], - key_p256dh: subscription_params[:keys][:p256dh], - key_auth: subscription_params[:keys][:auth], - data: data, - user_id: active_session.user_id, - access_token_id: active_session.access_token_id - ) - - active_session.update!(web_push_subscription: push_subscription) - - render json: push_subscription, serializer: REST::WebPushSubscriptionSerializer + render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer end def update @@ -43,6 +19,41 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController private + def active_session + @active_session ||= current_session + end + + def destroy_previous_subscriptions + active_session.web_push_subscription.destroy! + active_session.update!(web_push_subscription: nil) + end + + def prior_subscriptions? + active_session.web_push_subscription.present? + end + + def subscription_data + default_subscription_data.tap do |data| + data.deep_merge!(data_params) if params[:data] + end + end + + def default_subscription_data + { + policy: 'all', + alerts: Notification::TYPES.index_with { alerts_enabled }, + } + end + + def alerts_enabled + # Mobile devices do not support regular notifications, so we enable push notifications by default + active_session.detection.device.mobile? || active_session.detection.device.tablet? + end + + def update_session_with_subscription + active_session.update!(web_push_subscription: @push_subscription) + end + def set_push_subscription @push_subscription = ::Web::PushSubscription.find(params[:id]) end @@ -51,6 +62,17 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController @subscription_params ||= params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh]) end + def web_push_subscription_params + { + access_token_id: active_session.access_token_id, + data: subscription_data, + endpoint: subscription_params[:endpoint], + key_auth: subscription_params[:keys][:auth], + key_p256dh: subscription_params[:keys][:p256dh], + user_id: active_session.user_id, + } + end + def data_params @data_params ||= params.require(:data).permit(:policy, alerts: Notification::TYPES) end diff --git a/app/javascript/flavours/glitch/features/compose/components/search.jsx b/app/javascript/flavours/glitch/features/compose/components/search.jsx index 64732068ed..2944be798f 100644 --- a/app/javascript/flavours/glitch/features/compose/components/search.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/search.jsx @@ -271,6 +271,7 @@ class Search extends PureComponent { } _calculateOptions (value) { + const { signedIn } = this.context.identity; const trimmedValue = value.trim(); const options = []; @@ -295,7 +296,7 @@ class Search extends PureComponent { const couldBeStatusSearch = searchEnabled; - if (couldBeStatusSearch) { + if (couldBeStatusSearch && signedIn) { options.push({ key: 'status-search', label: {trimmedValue} }} />, action: this.handleStatusSearch }); } @@ -372,7 +373,7 @@ class Search extends PureComponent {

- {searchEnabled ? ( + {searchEnabled && signedIn ? (
{this.defaultOptions.map(({ key, label, action }, i) => (
) : (
- + {searchEnabled ? ( + + ) : ( + + )}
)} diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.jsx b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.jsx index 28dab45dcb..8fd2b98883 100644 --- a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.jsx +++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.jsx @@ -162,7 +162,7 @@ class Footer extends ImmutablePureComponent { onClose(); } - history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`); + this.props.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`); }; render () { diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss index 940b5cd206..87127196cb 100644 --- a/app/javascript/flavours/glitch/styles/components/columns.scss +++ b/app/javascript/flavours/glitch/styles/components/columns.scss @@ -400,8 +400,7 @@ $ui-header-height: 55px; > .scrollable { background: $ui-base-color; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; + border-radius: 0 0 4px 4px; } } diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index e0448f004c..9f3bbba033 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -661,3 +661,18 @@ export function unpinAccountFail(error) { error, }; } + +export const updateAccount = ({ displayName, note, avatar, header, discoverable, indexable }) => (dispatch, getState) => { + const data = new FormData(); + + data.append('display_name', displayName); + data.append('note', note); + if (avatar) data.append('avatar', avatar); + if (header) data.append('header', header); + data.append('discoverable', discoverable); + data.append('indexable', indexable); + + return api(getState).patch('/api/v1/accounts/update_credentials', data).then(response => { + dispatch(importFetchedAccount(response.data)); + }); +}; diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts index 985abf9463..5bf3e64288 100644 --- a/app/javascript/mastodon/api_types/accounts.ts +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -20,6 +20,7 @@ export interface ApiAccountJSON { bot: boolean; created_at: string; discoverable: boolean; + indexable: boolean; display_name: string; emojis: ApiCustomEmojiJSON[]; fields: ApiAccountFieldJSON[]; diff --git a/app/javascript/mastodon/components/admin/Retention.jsx b/app/javascript/mastodon/components/admin/Retention.jsx index 2f56710682..1e8ef48b7a 100644 --- a/app/javascript/mastodon/components/admin/Retention.jsx +++ b/app/javascript/mastodon/components/admin/Retention.jsx @@ -51,7 +51,7 @@ export default class Retention extends PureComponent { let content; if (loading) { - content = ; + content = ; } else { content = ( diff --git a/app/javascript/mastodon/components/copy_icon_button.jsx b/app/javascript/mastodon/components/copy_icon_button.jsx new file mode 100644 index 0000000000..9b1a36d83a --- /dev/null +++ b/app/javascript/mastodon/components/copy_icon_button.jsx @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import { useState, useCallback } from 'react'; + +import { defineMessages } from 'react-intl'; + +import classNames from 'classnames'; + +import { useDispatch } from 'react-redux'; + +import { ReactComponent as ContentCopyIcon } from '@material-symbols/svg-600/outlined/content_copy.svg'; + +import { showAlert } from 'mastodon/actions/alerts'; +import { IconButton } from 'mastodon/components/icon_button'; + +const messages = defineMessages({ + copied: { id: 'copy_icon_button.copied', defaultMessage: 'Copied to clipboard' }, +}); + +export const CopyIconButton = ({ title, value, className }) => { + const [copied, setCopied] = useState(false); + const dispatch = useDispatch(); + + const handleClick = useCallback(() => { + navigator.clipboard.writeText(value); + setCopied(true); + dispatch(showAlert({ message: messages.copied })); + setTimeout(() => setCopied(false), 700); + }, [setCopied, value, dispatch]); + + return ( + + ); +}; + +CopyIconButton.propTypes = { + title: PropTypes.string, + value: PropTypes.string, + className: PropTypes.string, +}; diff --git a/app/javascript/mastodon/components/loading_indicator.tsx b/app/javascript/mastodon/components/loading_indicator.tsx index 6bc24a0d61..fcdbe80d8a 100644 --- a/app/javascript/mastodon/components/loading_indicator.tsx +++ b/app/javascript/mastodon/components/loading_indicator.tsx @@ -1,7 +1,23 @@ +import { useIntl, defineMessages } from 'react-intl'; + import { CircularProgress } from './circular_progress'; -export const LoadingIndicator: React.FC = () => ( -
- -
-); +const messages = defineMessages({ + loading: { id: 'loading_indicator.label', defaultMessage: 'Loading…' }, +}); + +export const LoadingIndicator: React.FC = () => { + const intl = useIntl(); + + return ( +
+ +
+ ); +}; diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx index 7594135a4e..29b46cb43d 100644 --- a/app/javascript/mastodon/features/account/components/header.jsx +++ b/app/javascript/mastodon/features/account/components/header.jsx @@ -14,10 +14,12 @@ import { ReactComponent as LockIcon } from '@material-symbols/svg-600/outlined/l import { ReactComponent as MoreHorizIcon } from '@material-symbols/svg-600/outlined/more_horiz.svg'; import { ReactComponent as NotificationsIcon } from '@material-symbols/svg-600/outlined/notifications.svg'; import { ReactComponent as NotificationsActiveIcon } from '@material-symbols/svg-600/outlined/notifications_active-fill.svg'; +import { ReactComponent as ShareIcon } from '@material-symbols/svg-600/outlined/share.svg'; import { Avatar } from 'mastodon/components/avatar'; import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge'; import { Button } from 'mastodon/components/button'; +import { CopyIconButton } from 'mastodon/components/copy_icon_button'; import { FollowersCounter, FollowingCounter, StatusesCounter } from 'mastodon/components/counters'; import { Icon } from 'mastodon/components/icon'; import { IconButton } from 'mastodon/components/icon_button'; @@ -46,6 +48,7 @@ const messages = defineMessages({ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, report: { id: 'account.report', defaultMessage: 'Report @{name}' }, share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' }, + copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' }, media: { id: 'account.media', defaultMessage: 'Media' }, blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, @@ -245,11 +248,10 @@ class Header extends ImmutablePureComponent { const isRemote = account.get('acct') !== account.get('username'); const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null; - let info = []; - let actionBtn = ''; - let bellBtn = ''; - let lockedIcon = ''; - let menu = []; + let actionBtn, bellBtn, lockedIcon, shareBtn; + + let info = []; + let menu = []; if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { info.push(); @@ -267,6 +269,12 @@ class Header extends ImmutablePureComponent { bellBtn = ; } + if ('share' in navigator) { + shareBtn = ; + } else { + shareBtn = ; + } + if (me !== account.get('id')) { if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded actionBtn = ''; @@ -297,10 +305,6 @@ class Header extends ImmutablePureComponent { if (isRemote) { menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: account.get('url') }); - } - - if ('share' in navigator && !account.get('suspended')) { - menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); menu.push(null); } @@ -414,6 +418,7 @@ class Header extends ImmutablePureComponent { <> {actionBtn} {bellBtn} + {shareBtn} )} diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx index caae965a63..5d55330dcb 100644 --- a/app/javascript/mastodon/features/compose/components/search.jsx +++ b/app/javascript/mastodon/features/compose/components/search.jsx @@ -275,6 +275,7 @@ class Search extends PureComponent { } _calculateOptions (value) { + const { signedIn } = this.context.identity; const trimmedValue = value.trim(); const options = []; @@ -299,7 +300,7 @@ class Search extends PureComponent { const couldBeStatusSearch = searchEnabled; - if (couldBeStatusSearch) { + if (couldBeStatusSearch && signedIn) { options.push({ key: 'status-search', label: {trimmedValue} }} />, action: this.handleStatusSearch }); } @@ -376,7 +377,7 @@ class Search extends PureComponent {

- {searchEnabled ? ( + {searchEnabled && signedIn ? (
{this.defaultOptions.map(({ key, label, action }, i) => (
) : (
- + {searchEnabled ? ( + + ) : ( + + )}
)} diff --git a/app/javascript/mastodon/features/onboarding/components/progress_indicator.jsx b/app/javascript/mastodon/features/onboarding/components/progress_indicator.jsx deleted file mode 100644 index 37288a286f..0000000000 --- a/app/javascript/mastodon/features/onboarding/components/progress_indicator.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types'; -import { Fragment } from 'react'; - -import classNames from 'classnames'; - -import { ReactComponent as CheckIcon } from '@material-symbols/svg-600/outlined/done.svg'; - -import { Icon } from 'mastodon/components/icon'; - -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; diff --git a/app/javascript/mastodon/features/onboarding/components/step.jsx b/app/javascript/mastodon/features/onboarding/components/step.jsx index 1f42d9d499..1f83f20801 100644 --- a/app/javascript/mastodon/features/onboarding/components/step.jsx +++ b/app/javascript/mastodon/features/onboarding/components/step.jsx @@ -1,11 +1,13 @@ import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; + import { ReactComponent as ArrowRightAltIcon } from '@material-symbols/svg-600/outlined/arrow_right_alt.svg'; import { ReactComponent as CheckIcon } from '@material-symbols/svg-600/outlined/done.svg'; -import { Icon } from 'mastodon/components/icon'; +import { Icon } from 'mastodon/components/icon'; -const Step = ({ label, description, icon, iconComponent, completed, onClick, href }) => { +export const Step = ({ label, description, icon, iconComponent, completed, onClick, href, to }) => { const content = ( <>
@@ -29,6 +31,12 @@ const Step = ({ label, description, icon, iconComponent, completed, onClick, hre {content} ); + } else if (to) { + return ( + + {content} + + ); } return ( @@ -45,7 +53,6 @@ Step.propTypes = { iconComponent: PropTypes.func, completed: PropTypes.bool, href: PropTypes.string, + to: PropTypes.string, onClick: PropTypes.func, }; - -export default Step; diff --git a/app/javascript/mastodon/features/onboarding/follows.jsx b/app/javascript/mastodon/features/onboarding/follows.jsx index e21c7c75b6..e23a335c06 100644 --- a/app/javascript/mastodon/features/onboarding/follows.jsx +++ b/app/javascript/mastodon/features/onboarding/follows.jsx @@ -1,79 +1,62 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; +import { useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; + +import { useDispatch } from 'react-redux'; + import { fetchSuggestions } from 'mastodon/actions/suggestions'; import { markAsPartial } from 'mastodon/actions/timelines'; -import Column from 'mastodon/components/column'; import { ColumnBackButton } from 'mastodon/components/column_back_button'; import { EmptyAccount } from 'mastodon/components/empty_account'; import Account from 'mastodon/containers/account_container'; +import { useAppSelector } from 'mastodon/store'; -const mapStateToProps = state => ({ - suggestions: state.getIn(['suggestions', 'items']), - isLoading: state.getIn(['suggestions', 'isLoading']), -}); +export const Follows = () => { + const dispatch = useDispatch(); + const isLoading = useAppSelector(state => state.getIn(['suggestions', 'isLoading'])); + const suggestions = useAppSelector(state => state.getIn(['suggestions', 'items'])); -class Follows extends PureComponent { - - static propTypes = { - onBack: PropTypes.func, - dispatch: PropTypes.func.isRequired, - suggestions: ImmutablePropTypes.list, - isLoading: PropTypes.bool, - }; - - componentDidMount () { - const { dispatch } = this.props; + useEffect(() => { dispatch(fetchSuggestions(true)); + + return () => { + dispatch(markAsPartial('home')); + }; + }, [dispatch]); + + let loadedContent; + + if (isLoading) { + loadedContent = (new Array(8)).fill().map((_, i) => ); + } else if (suggestions.isEmpty()) { + loadedContent =
; + } else { + loadedContent = suggestions.map(suggestion => ); } - componentWillUnmount () { - const { dispatch } = this.props; - dispatch(markAsPartial('home')); - } + return ( + <> + - render () { - const { onBack, isLoading, suggestions } = 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} -
- -

{chunks} }} />

- -
- -
+
+
+

+

- - ); - } -} +
+ {loadedContent} +
-export default connect(mapStateToProps)(Follows); +

{chunks} }} />

+ +
+ +
+
+ + ); +}; diff --git a/app/javascript/mastodon/features/onboarding/index.jsx b/app/javascript/mastodon/features/onboarding/index.jsx index 51d4b71f24..51677fbc7a 100644 --- a/app/javascript/mastodon/features/onboarding/index.jsx +++ b/app/javascript/mastodon/features/onboarding/index.jsx @@ -1,152 +1,90 @@ -import PropTypes from 'prop-types'; +import { useCallback } from 'react'; -import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; +import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; import { Helmet } from 'react-helmet'; -import { Link, withRouter } from 'react-router-dom'; +import { Link, Switch, Route, useHistory } from 'react-router-dom'; + +import { useDispatch } from 'react-redux'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; import { ReactComponent as AccountCircleIcon } from '@material-symbols/svg-600/outlined/account_circle.svg'; import { ReactComponent as ArrowRightAltIcon } from '@material-symbols/svg-600/outlined/arrow_right_alt.svg'; import { ReactComponent as ContentCopyIcon } from '@material-symbols/svg-600/outlined/content_copy.svg'; import { ReactComponent as EditNoteIcon } from '@material-symbols/svg-600/outlined/edit_note.svg'; import { ReactComponent as PersonAddIcon } from '@material-symbols/svg-600/outlined/person_add.svg'; -import { debounce } from 'lodash'; import illustration from 'mastodon/../images/elephant_ui_conversation.svg'; -import { fetchAccount } from 'mastodon/actions/accounts'; import { focusCompose } from 'mastodon/actions/compose'; -import { closeOnboarding } from 'mastodon/actions/onboarding'; import { Icon } from 'mastodon/components/icon'; import Column from 'mastodon/features/ui/components/column'; import { me } from 'mastodon/initial_state'; -import { makeGetAccount } from 'mastodon/selectors'; +import { useAppSelector } from 'mastodon/store'; import { assetHost } from 'mastodon/utils/config'; -import { WithRouterPropTypes } from 'mastodon/utils/react_router'; -import Step from './components/step'; -import Follows from './follows'; -import Share from './share'; +import { Step } from './components/step'; +import { Follows } from './follows'; +import { Profile } from './profile'; +import { Share } from './share'; const messages = defineMessages({ template: { id: 'onboarding.compose.template', defaultMessage: 'Hello #Mastodon!' }, }); -const mapStateToProps = () => { - const getAccount = makeGetAccount(); +const Onboarding = () => { + const account = useAppSelector(state => state.getIn(['accounts', me])); + const dispatch = useDispatch(); + const intl = useIntl(); + const history = useHistory(); - return state => ({ - account: getAccount(state, me), - }); + const handleComposeClick = useCallback(() => { + dispatch(focusCompose(history, intl.formatMessage(messages.template))); + }, [dispatch, intl, history]); + + return ( + + + +
+
+ +

+

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

+ +
+ + + + + + + + + +
+
+
+ + + + +
+ + + + +
+ ); }; -class Onboarding extends ImmutablePureComponent { - static propTypes = { - dispatch: PropTypes.func.isRequired, - account: ImmutablePropTypes.record, - ...WithRouterPropTypes, - }; - - state = { - step: null, - profileClicked: false, - shareClicked: false, - }; - - handleClose = () => { - const { dispatch, history } = this.props; - - dispatch(closeOnboarding()); - history.push('/home'); - }; - - handleProfileClick = () => { - this.setState({ profileClicked: true }); - }; - - handleFollowClick = () => { - this.setState({ step: 'follows' }); - }; - - handleComposeClick = () => { - const { dispatch, intl, history } = this.props; - - dispatch(focusCompose(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' iconComponent={AccountCircleIcon} label={} description={} /> - = 7} icon='user-plus' iconComponent={PersonAddIcon} label={} description={} /> - = 1} icon='pencil-square-o' iconComponent={EditNoteIcon} label={} description={ }} />} /> - } description={} /> -
- -

- -
- - - - - - - - - -
-
- - - - -
- ); - } - -} - -export default withRouter(connect(mapStateToProps)(injectIntl(Onboarding))); +export default Onboarding; diff --git a/app/javascript/mastodon/features/onboarding/profile.jsx b/app/javascript/mastodon/features/onboarding/profile.jsx new file mode 100644 index 0000000000..09e6b2c6c6 --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/profile.jsx @@ -0,0 +1,160 @@ +import { useState, useMemo, useCallback, createRef } from 'react'; + +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { useHistory } from 'react-router-dom'; + + +import { useDispatch } from 'react-redux'; + +import { ReactComponent as AddPhotoAlternateIcon } from '@material-symbols/svg-600/outlined/add_photo_alternate.svg'; +import { ReactComponent as EditIcon } from '@material-symbols/svg-600/outlined/edit.svg'; +import Toggle from 'react-toggle'; + +import { updateAccount } from 'mastodon/actions/accounts'; +import { Button } from 'mastodon/components/button'; +import { ColumnBackButton } from 'mastodon/components/column_back_button'; +import { Icon } from 'mastodon/components/icon'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import { me } from 'mastodon/initial_state'; +import { useAppSelector } from 'mastodon/store'; +import { unescapeHTML } from 'mastodon/utils/html'; + +const messages = defineMessages({ + uploadHeader: { id: 'onboarding.profile.upload_header', defaultMessage: 'Upload profile header' }, + uploadAvatar: { id: 'onboarding.profile.upload_avatar', defaultMessage: 'Upload profile picture' }, +}); + +export const Profile = () => { + const account = useAppSelector(state => state.getIn(['accounts', me])); + const [displayName, setDisplayName] = useState(account.get('display_name')); + const [note, setNote] = useState(unescapeHTML(account.get('note'))); + const [avatar, setAvatar] = useState(null); + const [header, setHeader] = useState(null); + const [discoverable, setDiscoverable] = useState(account.get('discoverable')); + const [isSaving, setIsSaving] = useState(false); + const [errors, setErrors] = useState(); + const avatarFileRef = createRef(); + const headerFileRef = createRef(); + const dispatch = useDispatch(); + const intl = useIntl(); + const history = useHistory(); + + const handleDisplayNameChange = useCallback(e => { + setDisplayName(e.target.value); + }, [setDisplayName]); + + const handleNoteChange = useCallback(e => { + setNote(e.target.value); + }, [setNote]); + + const handleDiscoverableChange = useCallback(e => { + setDiscoverable(e.target.checked); + }, [setDiscoverable]); + + const handleAvatarChange = useCallback(e => { + setAvatar(e.target?.files?.[0]); + }, [setAvatar]); + + const handleHeaderChange = useCallback(e => { + setHeader(e.target?.files?.[0]); + }, [setHeader]); + + const avatarPreview = useMemo(() => avatar ? URL.createObjectURL(avatar) : account.get('avatar'), [avatar, account]); + const headerPreview = useMemo(() => header ? URL.createObjectURL(header) : account.get('header'), [header, account]); + + const handleSubmit = useCallback(() => { + setIsSaving(true); + + dispatch(updateAccount({ + displayName, + note, + avatar, + header, + discoverable, + indexable: discoverable, + })).then(() => history.push('/start/follows')).catch(err => { + setIsSaving(false); + setErrors(err.response.data.details); + }); + }, [dispatch, displayName, note, avatar, header, discoverable, history]); + + return ( + <> + + +
+
+

+

+
+ +
+
+ + + +
+ +
+ + +
+ +
+
+ +
+ + +
+