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/actions/account_notes.ts b/app/javascript/flavours/glitch/actions/account_notes.ts index dbe9ee2a9f..1fb683e0d3 100644 --- a/app/javascript/flavours/glitch/actions/account_notes.ts +++ b/app/javascript/flavours/glitch/actions/account_notes.ts @@ -1,3 +1,4 @@ +import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships'; import { createAppAsyncThunk } from 'flavours/glitch/store/typed_functions'; import api from '../api'; @@ -5,8 +6,7 @@ import api from '../api'; export const submitAccountNote = createAppAsyncThunk( 'account_note/submit', async (args: { id: string; value: string }, { getState }) => { - // TODO: replace `unknown` with `ApiRelationshipJSON` when it is merged - const response = await api(getState).post( + const response = await api(getState).post( `/api/v1/accounts/${args.id}/note`, { comment: args.value, diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js index bb03e68de8..a93c027def 100644 --- a/app/javascript/flavours/glitch/actions/accounts.js +++ b/app/javascript/flavours/glitch/actions/accounts.js @@ -1,5 +1,15 @@ import api, { getLinks } from '../api'; +import { + followAccountSuccess, unfollowAccountSuccess, + authorizeFollowRequestSuccess, rejectFollowRequestSuccess, + followAccountRequest, followAccountFail, + unfollowAccountRequest, unfollowAccountFail, + muteAccountSuccess, unmuteAccountSuccess, + blockAccountSuccess, unblockAccountSuccess, + pinAccountSuccess, unpinAccountSuccess, + fetchRelationshipsSuccess, +} from './accounts_typed'; import { importFetchedAccount, importFetchedAccounts } from './importer'; export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; @@ -10,36 +20,22 @@ export const ACCOUNT_LOOKUP_REQUEST = 'ACCOUNT_LOOKUP_REQUEST'; export const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS'; export const ACCOUNT_LOOKUP_FAIL = 'ACCOUNT_LOOKUP_FAIL'; -export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST'; -export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS'; -export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL'; - -export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST'; -export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS'; -export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL'; - export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST'; -export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS'; export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL'; export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; -export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS'; export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; -export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS'; export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; -export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; export const ACCOUNT_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST'; -export const ACCOUNT_PIN_SUCCESS = 'ACCOUNT_PIN_SUCCESS'; export const ACCOUNT_PIN_FAIL = 'ACCOUNT_PIN_FAIL'; export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST'; -export const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS'; export const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL'; export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; @@ -59,7 +55,6 @@ export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS'; export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL'; export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; -export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS'; export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST'; @@ -91,9 +86,10 @@ export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE = 'PINNED_ACCOUNTS_EDITOR export const PINNED_ACCOUNTS_EDITOR_RESET = 'PINNED_ACCOUNTS_EDITOR_RESET'; - export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL'; +export * from './accounts_typed'; + export function fetchAccount(id) { return (dispatch, getState) => { dispatch(fetchRelationships([id])); @@ -168,12 +164,12 @@ export function followAccount(id, options = { reblogs: true }) { const alreadyFollowing = getState().getIn(['relationships', id, 'following']); const locked = getState().getIn(['accounts', id, 'locked'], false); - dispatch(followAccountRequest(id, locked)); + dispatch(followAccountRequest({ id, locked })); api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => { - dispatch(followAccountSuccess(response.data, alreadyFollowing)); + dispatch(followAccountSuccess({relationship: response.data, alreadyFollowing})); }).catch(error => { - dispatch(followAccountFail(error, locked)); + dispatch(followAccountFail({ id, error, locked })); }); }; } @@ -183,74 +179,22 @@ export function unfollowAccount(id) { dispatch(unfollowAccountRequest(id)); api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => { - dispatch(unfollowAccountSuccess(response.data, getState().get('statuses'))); + dispatch(unfollowAccountSuccess({relationship: response.data, statuses: getState().get('statuses')})); }).catch(error => { - dispatch(unfollowAccountFail(error)); + dispatch(unfollowAccountFail({ id, error })); }); }; } -export function followAccountRequest(id, locked) { - return { - type: ACCOUNT_FOLLOW_REQUEST, - id, - locked, - skipLoading: true, - }; -} - -export function followAccountSuccess(relationship, alreadyFollowing) { - return { - type: ACCOUNT_FOLLOW_SUCCESS, - relationship, - alreadyFollowing, - skipLoading: true, - }; -} - -export function followAccountFail(error, locked) { - return { - type: ACCOUNT_FOLLOW_FAIL, - error, - locked, - skipLoading: true, - }; -} - -export function unfollowAccountRequest(id) { - return { - type: ACCOUNT_UNFOLLOW_REQUEST, - id, - skipLoading: true, - }; -} - -export function unfollowAccountSuccess(relationship, statuses) { - return { - type: ACCOUNT_UNFOLLOW_SUCCESS, - relationship, - statuses, - skipLoading: true, - }; -} - -export function unfollowAccountFail(error) { - return { - type: ACCOUNT_UNFOLLOW_FAIL, - error, - skipLoading: true, - }; -} - export function blockAccount(id) { return (dispatch, getState) => { dispatch(blockAccountRequest(id)); api(getState).post(`/api/v1/accounts/${id}/block`).then(response => { // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers - dispatch(blockAccountSuccess(response.data, getState().get('statuses'))); + dispatch(blockAccountSuccess({ relationship: response.data, statuses: getState().get('statuses') })); }).catch(error => { - dispatch(blockAccountFail(id, error)); + dispatch(blockAccountFail({ id, error })); }); }; } @@ -260,9 +204,9 @@ export function unblockAccount(id) { dispatch(unblockAccountRequest(id)); api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => { - dispatch(unblockAccountSuccess(response.data)); + dispatch(unblockAccountSuccess({ relationship: response.data })); }).catch(error => { - dispatch(unblockAccountFail(id, error)); + dispatch(unblockAccountFail({ id, error })); }); }; } @@ -273,15 +217,6 @@ export function blockAccountRequest(id) { id, }; } - -export function blockAccountSuccess(relationship, statuses) { - return { - type: ACCOUNT_BLOCK_SUCCESS, - relationship, - statuses, - }; -} - export function blockAccountFail(error) { return { type: ACCOUNT_BLOCK_FAIL, @@ -296,13 +231,6 @@ export function unblockAccountRequest(id) { }; } -export function unblockAccountSuccess(relationship) { - return { - type: ACCOUNT_UNBLOCK_SUCCESS, - relationship, - }; -} - export function unblockAccountFail(error) { return { type: ACCOUNT_UNBLOCK_FAIL, @@ -317,9 +245,9 @@ export function muteAccount(id, notifications, duration=0) { api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => { // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers - dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); + dispatch(muteAccountSuccess({ relationship: response.data, statuses: getState().get('statuses') })); }).catch(error => { - dispatch(muteAccountFail(id, error)); + dispatch(muteAccountFail({ id, error })); }); }; } @@ -329,9 +257,9 @@ export function unmuteAccount(id) { dispatch(unmuteAccountRequest(id)); api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => { - dispatch(unmuteAccountSuccess(response.data)); + dispatch(unmuteAccountSuccess({ relationship: response.data })); }).catch(error => { - dispatch(unmuteAccountFail(id, error)); + dispatch(unmuteAccountFail({ id, error })); }); }; } @@ -343,14 +271,6 @@ export function muteAccountRequest(id) { }; } -export function muteAccountSuccess(relationship, statuses) { - return { - type: ACCOUNT_MUTE_SUCCESS, - relationship, - statuses, - }; -} - export function muteAccountFail(error) { return { type: ACCOUNT_MUTE_FAIL, @@ -365,13 +285,6 @@ export function unmuteAccountRequest(id) { }; } -export function unmuteAccountSuccess(relationship) { - return { - type: ACCOUNT_UNMUTE_SUCCESS, - relationship, - }; -} - export function unmuteAccountFail(error) { return { type: ACCOUNT_UNMUTE_FAIL, @@ -568,7 +481,7 @@ export function fetchRelationships(accountIds) { dispatch(fetchRelationshipsRequest(newAccountIds)); api(getState).get(`/api/v1/accounts/relationships?with_suspended=true&${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { - dispatch(fetchRelationshipsSuccess(response.data)); + dispatch(fetchRelationshipsSuccess({ relationships: response.data })); }).catch(error => { dispatch(fetchRelationshipsFail(error)); }); @@ -583,14 +496,6 @@ export function fetchRelationshipsRequest(ids) { }; } -export function fetchRelationshipsSuccess(relationships) { - return { - type: RELATIONSHIPS_FETCH_SUCCESS, - relationships, - skipLoading: true, - }; -} - export function fetchRelationshipsFail(error) { return { type: RELATIONSHIPS_FETCH_FAIL, @@ -678,7 +583,7 @@ export function authorizeFollowRequest(id) { api(getState) .post(`/api/v1/follow_requests/${id}/authorize`) - .then(() => dispatch(authorizeFollowRequestSuccess(id))) + .then(() => dispatch(authorizeFollowRequestSuccess({ id }))) .catch(error => dispatch(authorizeFollowRequestFail(id, error))); }; } @@ -690,13 +595,6 @@ export function authorizeFollowRequestRequest(id) { }; } -export function authorizeFollowRequestSuccess(id) { - return { - type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS, - id, - }; -} - export function authorizeFollowRequestFail(id, error) { return { type: FOLLOW_REQUEST_AUTHORIZE_FAIL, @@ -712,7 +610,7 @@ export function rejectFollowRequest(id) { api(getState) .post(`/api/v1/follow_requests/${id}/reject`) - .then(() => dispatch(rejectFollowRequestSuccess(id))) + .then(() => dispatch(rejectFollowRequestSuccess({ id }))) .catch(error => dispatch(rejectFollowRequestFail(id, error))); }; } @@ -724,13 +622,6 @@ export function rejectFollowRequestRequest(id) { }; } -export function rejectFollowRequestSuccess(id) { - return { - type: FOLLOW_REQUEST_REJECT_SUCCESS, - id, - }; -} - export function rejectFollowRequestFail(id, error) { return { type: FOLLOW_REQUEST_REJECT_FAIL, @@ -744,7 +635,7 @@ export function pinAccount(id) { dispatch(pinAccountRequest(id)); api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => { - dispatch(pinAccountSuccess(response.data)); + dispatch(pinAccountSuccess({ relationship: response.data })); }).catch(error => { dispatch(pinAccountFail(error)); }); @@ -756,7 +647,7 @@ export function unpinAccount(id) { dispatch(unpinAccountRequest(id)); api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => { - dispatch(unpinAccountSuccess(response.data)); + dispatch(unpinAccountSuccess({ relationship: response.data })); }).catch(error => { dispatch(unpinAccountFail(error)); }); @@ -770,13 +661,6 @@ export function pinAccountRequest(id) { }; } -export function pinAccountSuccess(relationship) { - return { - type: ACCOUNT_PIN_SUCCESS, - relationship, - }; -} - export function pinAccountFail(error) { return { type: ACCOUNT_PIN_FAIL, @@ -791,13 +675,6 @@ export function unpinAccountRequest(id) { }; } -export function unpinAccountSuccess(relationship) { - return { - type: ACCOUNT_UNPIN_SUCCESS, - relationship, - }; -} - export function unpinAccountFail(error) { return { type: ACCOUNT_UNPIN_FAIL, @@ -805,11 +682,6 @@ export function unpinAccountFail(error) { }; } -export const revealAccount = id => ({ - type: ACCOUNT_REVEAL, - id, -}); - export function fetchPinnedAccounts() { return (dispatch, getState) => { dispatch(fetchPinnedAccountsRequest()); diff --git a/app/javascript/flavours/glitch/actions/accounts_typed.ts b/app/javascript/flavours/glitch/actions/accounts_typed.ts new file mode 100644 index 0000000000..22aaa48a0d --- /dev/null +++ b/app/javascript/flavours/glitch/actions/accounts_typed.ts @@ -0,0 +1,97 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts'; +import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships'; + +export const revealAccount = createAction<{ + id: string; +}>('accounts/revealAccount'); + +export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>( + 'accounts/importAccounts', +); + +function actionWithSkipLoadingTrue(args: Args) { + return { + payload: { + ...args, + skipLoading: true, + }, + }; +} + +export const followAccountSuccess = createAction( + 'accounts/followAccount/SUCCESS', + actionWithSkipLoadingTrue<{ + relationship: ApiRelationshipJSON; + alreadyFollowing: boolean; + }>, +); + +export const unfollowAccountSuccess = createAction( + 'accounts/unfollowAccount/SUCCESS', + actionWithSkipLoadingTrue<{ + relationship: ApiRelationshipJSON; + statuses: unknown; + alreadyFollowing?: boolean; + }>, +); + +export const authorizeFollowRequestSuccess = createAction<{ id: string }>( + 'accounts/followRequestAuthorize/SUCCESS', +); + +export const rejectFollowRequestSuccess = createAction<{ id: string }>( + 'accounts/followRequestReject/SUCCESS', +); + +export const followAccountRequest = createAction( + 'accounts/follow/REQUEST', + actionWithSkipLoadingTrue<{ id: string; locked: boolean }>, +); + +export const followAccountFail = createAction( + 'accounts/follow/FAIL', + actionWithSkipLoadingTrue<{ id: string; error: string; locked: boolean }>, +); + +export const unfollowAccountRequest = createAction( + 'accounts/unfollow/REQUEST', + actionWithSkipLoadingTrue<{ id: string }>, +); + +export const unfollowAccountFail = createAction( + 'accounts/unfollow/FAIL', + actionWithSkipLoadingTrue<{ id: string; error: string }>, +); + +export const blockAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; + statuses: unknown; +}>('accounts/block/SUCCESS'); + +export const unblockAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; +}>('accounts/unblock/SUCCESS'); + +export const muteAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; + statuses: unknown; +}>('accounts/mute/SUCCESS'); + +export const unmuteAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; +}>('accounts/unmute/SUCCESS'); + +export const pinAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; +}>('accounts/pin/SUCCESS'); + +export const unpinAccountSuccess = createAction<{ + relationship: ApiRelationshipJSON; +}>('accounts/unpin/SUCCESS'); + +export const fetchRelationshipsSuccess = createAction( + 'relationships/fetch/SUCCESS', + actionWithSkipLoadingTrue<{ relationships: ApiRelationshipJSON[] }>, +); diff --git a/app/javascript/flavours/glitch/actions/domain_blocks.js b/app/javascript/flavours/glitch/actions/domain_blocks.js index d06de20a2d..718002613f 100644 --- a/app/javascript/flavours/glitch/actions/domain_blocks.js +++ b/app/javascript/flavours/glitch/actions/domain_blocks.js @@ -1,11 +1,13 @@ import api, { getLinks } from '../api'; +import { blockDomainSuccess, unblockDomainSuccess } from "./domain_blocks_typed"; + +export * from "./domain_blocks_typed"; + export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST'; -export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS'; export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL'; export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST'; -export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS'; export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL'; export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST'; @@ -24,7 +26,7 @@ export function blockDomain(domain) { const at_domain = '@' + domain; const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); - dispatch(blockDomainSuccess(domain, accounts)); + dispatch(blockDomainSuccess({ domain, accounts })); }).catch(err => { dispatch(blockDomainFail(domain, err)); }); @@ -38,14 +40,6 @@ export function blockDomainRequest(domain) { }; } -export function blockDomainSuccess(domain, accounts) { - return { - type: DOMAIN_BLOCK_SUCCESS, - domain, - accounts, - }; -} - export function blockDomainFail(domain, error) { return { type: DOMAIN_BLOCK_FAIL, @@ -61,7 +55,7 @@ export function unblockDomain(domain) { api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => { const at_domain = '@' + domain; const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); - dispatch(unblockDomainSuccess(domain, accounts)); + dispatch(unblockDomainSuccess({ domain, accounts })); }).catch(err => { dispatch(unblockDomainFail(domain, err)); }); @@ -75,14 +69,6 @@ export function unblockDomainRequest(domain) { }; } -export function unblockDomainSuccess(domain, accounts) { - return { - type: DOMAIN_UNBLOCK_SUCCESS, - domain, - accounts, - }; -} - export function unblockDomainFail(domain, error) { return { type: DOMAIN_UNBLOCK_FAIL, diff --git a/app/javascript/flavours/glitch/actions/domain_blocks_typed.ts b/app/javascript/flavours/glitch/actions/domain_blocks_typed.ts new file mode 100644 index 0000000000..c5c4a76ba6 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/domain_blocks_typed.ts @@ -0,0 +1,13 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { Account } from 'flavours/glitch/models/account'; + +export const blockDomainSuccess = createAction<{ + domain: string; + accounts: Account[]; +}>('domain_blocks/block/SUCCESS'); + +export const unblockDomainSuccess = createAction<{ + domain: string; + accounts: Account[]; +}>('domain_blocks/unblock/SUCCESS'); diff --git a/app/javascript/flavours/glitch/actions/importer/index.js b/app/javascript/flavours/glitch/actions/importer/index.js index 3d01a96dd8..5fbc9bb5bb 100644 --- a/app/javascript/flavours/glitch/actions/importer/index.js +++ b/app/javascript/flavours/glitch/actions/importer/index.js @@ -1,7 +1,7 @@ -import { normalizeAccount, normalizeStatus, normalizePoll } from './normalizer'; +import { importAccounts } from '../accounts_typed'; + +import { normalizeStatus, normalizePoll } from './normalizer'; -export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; -export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; export const STATUS_IMPORT = 'STATUS_IMPORT'; export const STATUSES_IMPORT = 'STATUSES_IMPORT'; export const POLLS_IMPORT = 'POLLS_IMPORT'; @@ -13,14 +13,6 @@ function pushUnique(array, object) { } } -export function importAccount(account) { - return { type: ACCOUNT_IMPORT, account }; -} - -export function importAccounts(accounts) { - return { type: ACCOUNTS_IMPORT, accounts }; -} - export function importStatus(status) { return { type: STATUS_IMPORT, status }; } @@ -45,7 +37,7 @@ export function importFetchedAccounts(accounts) { const normalAccounts = []; function processAccount(account) { - pushUnique(normalAccounts, normalizeAccount(account)); + pushUnique(normalAccounts, account); if (account.moved) { processAccount(account.moved); @@ -54,7 +46,7 @@ export function importFetchedAccounts(accounts) { accounts.forEach(processAccount); - return importAccounts(normalAccounts); + return importAccounts({ accounts: normalAccounts }); } export function importFetchedStatus(status) { diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js index 506e5ee6ad..c2ad0f9908 100644 --- a/app/javascript/flavours/glitch/actions/importer/normalizer.js +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -2,7 +2,6 @@ import escapeTextContentForBrowser from 'escape-html'; import emojify from '../../features/emoji/emoji'; import { autoHideCW } from '../../utils/content_warning'; -import { unescapeHTML } from '../../utils/html'; const domParser = new DOMParser(); @@ -17,32 +16,6 @@ export function searchTextFromRawStatus (status) { return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; } -export function normalizeAccount(account) { - account = { ...account }; - - const emojiMap = makeEmojiMap(account.emojis); - const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name; - - account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap); - account.note_emojified = emojify(account.note, emojiMap); - account.note_plain = unescapeHTML(account.note); - - if (account.fields) { - account.fields = account.fields.map(pair => ({ - ...pair, - name_emojified: emojify(escapeTextContentForBrowser(pair.name), emojiMap), - value_emojified: emojify(pair.value, emojiMap), - value_plain: unescapeHTML(pair.value), - })); - } - - if (account.moved) { - account.moved = account.moved.id; - } - - return account; -} - export function normalizeFilterResult(result) { const normalResult = { ...result }; diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index e63f10359b..6a0bf01b90 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -18,10 +18,12 @@ import { importFetchedStatuses, } from './importer'; import { submitMarkers } from './markers'; +import { notificationsUpdate } from "./notifications_typed"; import { register as registerPushNotifications } from './push_notifications'; import { saveSettings } from './settings'; -export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; +export * from "./notifications_typed"; + export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; // tracking the notif cleaning request @@ -107,12 +109,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) { dispatch(importFetchedAccount(notification.report.target_account)); } - dispatch({ - type: NOTIFICATIONS_UPDATE, - notification, - usePendingItems: preferPendingItems, - meta: (playSound && !filtered) ? { sound: 'boop' } : undefined, - }); + + dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered})); fetchRelatedRelationships(dispatch, [notification]); } else if (playSound && !filtered) { diff --git a/app/javascript/flavours/glitch/actions/notifications_typed.ts b/app/javascript/flavours/glitch/actions/notifications_typed.ts new file mode 100644 index 0000000000..176362f4b1 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/notifications_typed.ts @@ -0,0 +1,23 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { ApiAccountJSON } from '../api_types/accounts'; +// To be replaced once ApiNotificationJSON type exists +interface FakeApiNotificationJSON { + type: string; + account: ApiAccountJSON; +} + +export const notificationsUpdate = createAction( + 'notifications/update', + ({ + playSound, + ...args + }: { + notification: FakeApiNotificationJSON; + usePendingItems: boolean; + playSound: boolean; + }) => ({ + payload: args, + meta: { sound: playSound ? 'boop' : undefined }, + }), +); diff --git a/app/javascript/flavours/glitch/actions/store.js b/app/javascript/flavours/glitch/actions/store.js index da07142b3b..4a33d7ef87 100644 --- a/app/javascript/flavours/glitch/actions/store.js +++ b/app/javascript/flavours/glitch/actions/store.js @@ -25,6 +25,7 @@ const applyMigrations = (state) => { }); }; + export function hydrateStore(rawState) { return dispatch => { const state = applyMigrations(convertState(rawState)); diff --git a/app/javascript/flavours/glitch/api_types/accounts.ts b/app/javascript/flavours/glitch/api_types/accounts.ts new file mode 100644 index 0000000000..ce55dc604a --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/accounts.ts @@ -0,0 +1,45 @@ +import type { ApiCustomEmojiJSON } from './custom_emoji'; + +export interface ApiAccountFieldJSON { + name: string; + value: string; + verified_at: string | null; +} + +export interface ApiAccountRoleJSON { + color: string; + id: string; + name: string; +} + +// See app/serializers/rest/account_serializer.rb +export interface ApiAccountJSON { + acct: string; + avatar: string; + avatar_static: string; + bot: boolean; + created_at: string; + discoverable: boolean; + display_name: string; + emojis: ApiCustomEmojiJSON[]; + fields: ApiAccountFieldJSON[]; + followers_count: number; + following_count: number; + group: boolean; + header: string; + header_static: string; + id: string; + last_status_at: string; + locked: boolean; + noindex?: boolean; + note: string; + roles?: ApiAccountJSON[]; + statuses_count: number; + uri: string; + url: string; + username: string; + moved?: ApiAccountJSON; + suspended?: boolean; + limited?: boolean; + memorial?: boolean; +} diff --git a/app/javascript/flavours/glitch/api_types/custom_emoji.ts b/app/javascript/flavours/glitch/api_types/custom_emoji.ts new file mode 100644 index 0000000000..05144d6f68 --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/custom_emoji.ts @@ -0,0 +1,8 @@ +// See app/serializers/rest/account_serializer.rb +export interface ApiCustomEmojiJSON { + shortcode: string; + static_url: string; + url: string; + category?: string; + visible_in_picker: boolean; +} diff --git a/app/javascript/flavours/glitch/api_types/relationships.ts b/app/javascript/flavours/glitch/api_types/relationships.ts new file mode 100644 index 0000000000..9f26a0ce9b --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/relationships.ts @@ -0,0 +1,18 @@ +// See app/serializers/rest/relationship_serializer.rb +export interface ApiRelationshipJSON { + blocked_by: boolean; + blocking: boolean; + domain_blocking: boolean; + endorsed: boolean; + followed_by: boolean; + following: boolean; + id: string; + languages: string[] | null; + muting_notifications: boolean; + muting: boolean; + note: string; + notifying: boolean; + requested_by: boolean; + requested: boolean; + showing_reblogs: boolean; +} diff --git a/app/javascript/flavours/glitch/components/account.jsx b/app/javascript/flavours/glitch/components/account.jsx index 36d8a62b32..109e0daddd 100644 --- a/app/javascript/flavours/glitch/components/account.jsx +++ b/app/javascript/flavours/glitch/components/account.jsx @@ -36,7 +36,7 @@ class Account extends ImmutablePureComponent { static propTypes = { size: PropTypes.number, - account: ImmutablePropTypes.map, + account: ImmutablePropTypes.record, onFollow: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, diff --git a/app/javascript/flavours/glitch/components/avatar.tsx b/app/javascript/flavours/glitch/components/avatar.tsx index 7ed2d003ef..c45c26981f 100644 --- a/app/javascript/flavours/glitch/components/avatar.tsx +++ b/app/javascript/flavours/glitch/components/avatar.tsx @@ -1,8 +1,9 @@ import classNames from 'classnames'; +import type { Account } from 'flavours/glitch/models/account'; + import { useHovering } from '../hooks/useHovering'; import { autoPlayGif } from '../initial_state'; -import type { Account } from '../types/resources'; interface Props { account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there diff --git a/app/javascript/flavours/glitch/components/avatar_overlay.tsx b/app/javascript/flavours/glitch/components/avatar_overlay.tsx index da9c293856..cddca43d5b 100644 --- a/app/javascript/flavours/glitch/components/avatar_overlay.tsx +++ b/app/javascript/flavours/glitch/components/avatar_overlay.tsx @@ -1,6 +1,7 @@ +import type { Account } from 'flavours/glitch/models/account'; + import { useHovering } from '../hooks/useHovering'; import { autoPlayGif } from '../initial_state'; -import type { Account } from '../types/resources'; interface Props { account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there diff --git a/app/javascript/flavours/glitch/components/display_name.tsx b/app/javascript/flavours/glitch/components/display_name.tsx index 3ad00b5855..82b66b5748 100644 --- a/app/javascript/flavours/glitch/components/display_name.tsx +++ b/app/javascript/flavours/glitch/components/display_name.tsx @@ -4,8 +4,9 @@ import classNames from 'classnames'; import type { List } from 'immutable'; +import type { Account } from 'flavours/glitch/models/account'; + import { autoPlayGif } from '../initial_state'; -import type { Account } from '../types/resources'; import { Skeleton } from './skeleton'; diff --git a/app/javascript/flavours/glitch/components/inline_account.jsx b/app/javascript/flavours/glitch/components/inline_account.jsx index 98e44c05e0..ec3460ea4a 100644 --- a/app/javascript/flavours/glitch/components/inline_account.jsx +++ b/app/javascript/flavours/glitch/components/inline_account.jsx @@ -19,7 +19,7 @@ const makeMapStateToProps = () => { class InlineAccount extends PureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, }; render () { diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index 94754f0f29..141483dd01 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -80,7 +80,7 @@ class Status extends ImmutablePureComponent { containerId: PropTypes.string, id: PropTypes.string, status: ImmutablePropTypes.map, - account: ImmutablePropTypes.map, + account: ImmutablePropTypes.record, previousId: PropTypes.string, nextInReplyToId: PropTypes.string, rootId: PropTypes.string, diff --git a/app/javascript/flavours/glitch/features/account/components/account_note.jsx b/app/javascript/flavours/glitch/features/account/components/account_note.jsx index bab523acf6..272a4ee312 100644 --- a/app/javascript/flavours/glitch/features/account/components/account_note.jsx +++ b/app/javascript/flavours/glitch/features/account/components/account_note.jsx @@ -49,7 +49,7 @@ class InlineAlert extends PureComponent { class AccountNote extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, value: PropTypes.string, onSave: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, diff --git a/app/javascript/flavours/glitch/features/account/components/featured_tags.jsx b/app/javascript/flavours/glitch/features/account/components/featured_tags.jsx index 720acab43f..1b9e3572db 100644 --- a/app/javascript/flavours/glitch/features/account/components/featured_tags.jsx +++ b/app/javascript/flavours/glitch/features/account/components/featured_tags.jsx @@ -15,7 +15,7 @@ const messages = defineMessages({ class FeaturedTags extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.map, + account: ImmutablePropTypes.record, featuredTags: ImmutablePropTypes.list, tagged: PropTypes.string, intl: PropTypes.object.isRequired, diff --git a/app/javascript/flavours/glitch/features/account/components/follow_request_note.jsx b/app/javascript/flavours/glitch/features/account/components/follow_request_note.jsx index 7368ce9758..463499cbf8 100644 --- a/app/javascript/flavours/glitch/features/account/components/follow_request_note.jsx +++ b/app/javascript/flavours/glitch/features/account/components/follow_request_note.jsx @@ -8,7 +8,7 @@ import { Icon } from 'flavours/glitch/components/icon'; export default class FollowRequestNote extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, }; render () { diff --git a/app/javascript/flavours/glitch/features/account/components/header.jsx b/app/javascript/flavours/glitch/features/account/components/header.jsx index c4fb2162af..c2b827d9e1 100644 --- a/app/javascript/flavours/glitch/features/account/components/header.jsx +++ b/app/javascript/flavours/glitch/features/account/components/header.jsx @@ -83,7 +83,7 @@ const dateFormatOptions = { class Header extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.map, + account: ImmutablePropTypes.record, identity_props: ImmutablePropTypes.list, onFollow: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.jsx b/app/javascript/flavours/glitch/features/account_timeline/components/header.jsx index c38c1efa1b..eb2ddbdd80 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/header.jsx +++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.jsx @@ -18,7 +18,7 @@ import MovedNote from './moved_note'; class Header extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.map, + account: ImmutablePropTypes.record, onFollow: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/limited_account_hint.jsx b/app/javascript/flavours/glitch/features/account_timeline/components/limited_account_hint.jsx deleted file mode 100644 index d28ad77c55..0000000000 --- a/app/javascript/flavours/glitch/features/account_timeline/components/limited_account_hint.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import { connect } from 'react-redux'; - -import { revealAccount } from 'flavours/glitch/actions/accounts'; -import { Button } from 'flavours/glitch/components/button'; -import { domain } from 'flavours/glitch/initial_state'; - -const mapDispatchToProps = (dispatch, { accountId }) => ({ - - reveal () { - dispatch(revealAccount(accountId)); - }, - -}); - -class LimitedAccountHint extends PureComponent { - - static propTypes = { - accountId: PropTypes.string.isRequired, - reveal: PropTypes.func, - }; - - render () { - const { reveal } = this.props; - - return ( -
-

- -
- ); - } - -} - -export default connect(() => {}, mapDispatchToProps)(LimitedAccountHint); diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/limited_account_hint.tsx b/app/javascript/flavours/glitch/features/account_timeline/components/limited_account_hint.tsx new file mode 100644 index 0000000000..5613811d2f --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_timeline/components/limited_account_hint.tsx @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { revealAccount } from 'flavours/glitch/actions/accounts_typed'; +import { Button } from 'flavours/glitch/components/button'; +import { domain } from 'flavours/glitch/initial_state'; +import { useAppDispatch } from 'flavours/glitch/store'; + +export const LimitedAccountHint: React.FC<{ accountId: string }> = ({ + accountId, +}) => { + const dispatch = useAppDispatch(); + const reveal = useCallback(() => { + dispatch(revealAccount({ id: accountId })); + }, [dispatch, accountId]); + + return ( +
+

+ +

+ +
+ ); +}; diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.jsx b/app/javascript/flavours/glitch/features/account_timeline/index.jsx index d86951fcd3..cf99a1f569 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/index.jsx +++ b/app/javascript/flavours/glitch/features/account_timeline/index.jsx @@ -20,7 +20,7 @@ import { LoadingIndicator } from '../../components/loading_indicator'; import StatusList from '../../components/status_list'; import Column from '../ui/components/column'; -import LimitedAccountHint from './components/limited_account_hint'; +import { LimitedAccountHint } from './components/limited_account_hint'; import HeaderContainer from './containers/header_container'; const emptyList = ImmutableList(); diff --git a/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx b/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx index ff7d4d03dc..ebaacd7d9a 100644 --- a/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx @@ -28,7 +28,7 @@ const messages = defineMessages({ class ActionBar extends PureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, onLogout: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; diff --git a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.jsx b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.jsx index 3efa2edf0a..8ffc9062e5 100644 --- a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.jsx @@ -7,7 +7,7 @@ import { DisplayName } from '../../../components/display_name'; export default class AutosuggestAccount extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, }; render () { diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx index 85f97a5c91..9f88b0e92a 100644 --- a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx @@ -15,7 +15,7 @@ import ActionBar from './action_bar'; export default class NavigationBar extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, onLogout: PropTypes.func.isRequired, onClose: PropTypes.func, }; 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/directory/components/account_card.jsx b/app/javascript/flavours/glitch/features/directory/components/account_card.jsx index 92fa84201e..27b363bd08 100644 --- a/app/javascript/flavours/glitch/features/directory/components/account_card.jsx +++ b/app/javascript/flavours/glitch/features/directory/components/account_card.jsx @@ -104,7 +104,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ class AccountCard extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, intl: PropTypes.object.isRequired, onFollow: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, diff --git a/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.jsx b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.jsx index 54a75dca70..5746af1b39 100644 --- a/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.jsx +++ b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.jsx @@ -18,7 +18,7 @@ const messages = defineMessages({ class AccountAuthorize extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, onAuthorize: PropTypes.func.isRequired, onReject: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, diff --git a/app/javascript/flavours/glitch/features/followers/index.jsx b/app/javascript/flavours/glitch/features/followers/index.jsx index f3bcfbf271..6226c96f21 100644 --- a/app/javascript/flavours/glitch/features/followers/index.jsx +++ b/app/javascript/flavours/glitch/features/followers/index.jsx @@ -23,7 +23,7 @@ import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; import AccountContainer from '../../containers/account_container'; import ProfileColumnHeader from '../account/components/profile_column_header'; -import LimitedAccountHint from '../account_timeline/components/limited_account_hint'; +import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint'; import HeaderContainer from '../account_timeline/containers/header_container'; import Column from '../ui/components/column'; diff --git a/app/javascript/flavours/glitch/features/following/index.jsx b/app/javascript/flavours/glitch/features/following/index.jsx index 4ff59f6358..6993634302 100644 --- a/app/javascript/flavours/glitch/features/following/index.jsx +++ b/app/javascript/flavours/glitch/features/following/index.jsx @@ -23,7 +23,7 @@ import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; import AccountContainer from '../../containers/account_container'; import ProfileColumnHeader from '../account/components/profile_column_header'; -import LimitedAccountHint from '../account_timeline/components/limited_account_hint'; +import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint'; import HeaderContainer from '../account_timeline/containers/header_container'; import Column from '../ui/components/column'; diff --git a/app/javascript/flavours/glitch/features/list_adder/components/account.jsx b/app/javascript/flavours/glitch/features/list_adder/components/account.jsx index 31a2e96379..94a90726e3 100644 --- a/app/javascript/flavours/glitch/features/list_adder/components/account.jsx +++ b/app/javascript/flavours/glitch/features/list_adder/components/account.jsx @@ -21,7 +21,7 @@ const makeMapStateToProps = () => { class Account extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, }; render () { diff --git a/app/javascript/flavours/glitch/features/list_editor/components/account.jsx b/app/javascript/flavours/glitch/features/list_editor/components/account.jsx index 4618bd1c16..96b5e96df8 100644 --- a/app/javascript/flavours/glitch/features/list_editor/components/account.jsx +++ b/app/javascript/flavours/glitch/features/list_editor/components/account.jsx @@ -36,7 +36,7 @@ const mapDispatchToProps = (dispatch, { accountId }) => ({ class Account extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, intl: PropTypes.object.isRequired, onRemove: PropTypes.func.isRequired, onAdd: PropTypes.func.isRequired, diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow_request.jsx b/app/javascript/flavours/glitch/features/notifications/components/follow_request.jsx index 831095200a..e7131829b3 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/follow_request.jsx +++ b/app/javascript/flavours/glitch/features/notifications/components/follow_request.jsx @@ -27,7 +27,7 @@ const messages = defineMessages({ class FollowRequest extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, onAuthorize: PropTypes.func.isRequired, onReject: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, diff --git a/app/javascript/flavours/glitch/features/notifications/components/report.jsx b/app/javascript/flavours/glitch/features/notifications/components/report.jsx index decee64db4..0d80582e9f 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/report.jsx +++ b/app/javascript/flavours/glitch/features/notifications/components/report.jsx @@ -20,7 +20,7 @@ const messages = defineMessages({ class Report extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, report: ImmutablePropTypes.map.isRequired, hidden: PropTypes.bool, intl: PropTypes.object.isRequired, diff --git a/app/javascript/flavours/glitch/features/onboarding/index.jsx b/app/javascript/flavours/glitch/features/onboarding/index.jsx index 64cf42efac..3bce573a1d 100644 --- a/app/javascript/flavours/glitch/features/onboarding/index.jsx +++ b/app/javascript/flavours/glitch/features/onboarding/index.jsx @@ -42,7 +42,7 @@ const mapStateToProps = () => { class Onboarding extends ImmutablePureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, - account: ImmutablePropTypes.map, + account: ImmutablePropTypes.record, multiColumn: PropTypes.bool, ...WithRouterPropTypes, }; diff --git a/app/javascript/flavours/glitch/features/onboarding/share.jsx b/app/javascript/flavours/glitch/features/onboarding/share.jsx index 488096654c..84dc6d8e65 100644 --- a/app/javascript/flavours/glitch/features/onboarding/share.jsx +++ b/app/javascript/flavours/glitch/features/onboarding/share.jsx @@ -145,7 +145,7 @@ class Share extends PureComponent { static propTypes = { onBack: PropTypes.func, - account: ImmutablePropTypes.map, + account: ImmutablePropTypes.record, multiColumn: PropTypes.bool, intl: PropTypes.object, }; diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/header.jsx b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.jsx index 7a2902bbf0..246dac1808 100644 --- a/app/javascript/flavours/glitch/features/picture_in_picture/components/header.jsx +++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.jsx @@ -25,7 +25,7 @@ class Header extends ImmutablePureComponent { static propTypes = { accountId: PropTypes.string.isRequired, statusId: PropTypes.string.isRequired, - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, onClose: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; diff --git a/app/javascript/flavours/glitch/features/report/thanks.jsx b/app/javascript/flavours/glitch/features/report/thanks.jsx index 7782022b77..c928536a8d 100644 --- a/app/javascript/flavours/glitch/features/report/thanks.jsx +++ b/app/javascript/flavours/glitch/features/report/thanks.jsx @@ -20,7 +20,7 @@ class Thanks extends PureComponent { static propTypes = { submitted: PropTypes.bool, onClose: PropTypes.func.isRequired, - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, dispatch: PropTypes.func.isRequired, }; diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx index be7266d746..4230c6dd9a 100644 --- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.jsx @@ -109,7 +109,7 @@ class FocalPointModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, isUploadingThumbnail: PropTypes.bool, onSave: PropTypes.func.isRequired, onChangeDescription: PropTypes.func.isRequired, diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/report_modal.jsx index 2cf273112b..e6d79f5ab1 100644 --- a/app/javascript/flavours/glitch/features/ui/components/report_modal.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.jsx @@ -40,7 +40,7 @@ class ReportModal extends ImmutablePureComponent { statusId: PropTypes.string, dispatch: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.record.isRequired, }; state = { diff --git a/app/javascript/flavours/glitch/initial_state.js b/app/javascript/flavours/glitch/initial_state.js index 9f5f8d4cd3..588faa0fb0 100644 --- a/app/javascript/flavours/glitch/initial_state.js +++ b/app/javascript/flavours/glitch/initial_state.js @@ -1,43 +1,5 @@ // @ts-check -/** - * @typedef Emoji - * @property {string} shortcode - * @property {string} static_url - * @property {string} url - */ - -/** - * @typedef AccountField - * @property {string} name - * @property {string} value - * @property {string} verified_at - */ - -/** - * @typedef Account - * @property {string} acct - * @property {string} avatar - * @property {string} avatar_static - * @property {boolean} bot - * @property {string} created_at - * @property {boolean=} discoverable - * @property {string} display_name - * @property {Emoji[]} emojis - * @property {AccountField[]} fields - * @property {number} followers_count - * @property {number} following_count - * @property {boolean} group - * @property {string} header - * @property {string} header_static - * @property {string} id - * @property {string=} last_status_at - * @property {boolean} locked - * @property {string} note - * @property {number} statuses_count - * @property {string} url - * @property {string} username - */ /** * @typedef {[code: string, name: string, localName: string]} InitialStateLanguage @@ -101,7 +63,7 @@ export const hasMultiColumnPath = initialPath === '/' /** * @typedef InitialState - * @property {Record} accounts + * @property {Record} accounts * @property {InitialStateLanguage[]} languages * @property {boolean=} critical_updates_pending * @property {InitialStateMeta} meta diff --git a/app/javascript/flavours/glitch/models/account.ts b/app/javascript/flavours/glitch/models/account.ts new file mode 100644 index 0000000000..9d1bc20d06 --- /dev/null +++ b/app/javascript/flavours/glitch/models/account.ts @@ -0,0 +1,149 @@ +import type { RecordOf } from 'immutable'; +import { List, Record as ImmutableRecord } from 'immutable'; + +import escapeTextContentForBrowser from 'escape-html'; + +import type { + ApiAccountFieldJSON, + ApiAccountRoleJSON, + ApiAccountJSON, +} from 'flavours/glitch/api_types/accounts'; +import type { ApiCustomEmojiJSON } from 'flavours/glitch/api_types/custom_emoji'; +import emojify from 'flavours/glitch/features/emoji/emoji'; +import { unescapeHTML } from 'flavours/glitch/utils/html'; + +import { CustomEmojiFactory } from './custom_emoji'; +import type { CustomEmoji } from './custom_emoji'; + +// AccountField +interface AccountFieldShape extends Required { + name_emojified: string; + value_emojified: string; + value_plain: string | null; +} + +type AccountField = RecordOf; + +const AccountFieldFactory = ImmutableRecord({ + name: '', + value: '', + verified_at: null, + name_emojified: '', + value_emojified: '', + value_plain: null, +}); + +// AccountRole +export type AccountRoleShape = ApiAccountRoleJSON; +export type AccountRole = RecordOf; + +const AccountRoleFactory = ImmutableRecord({ + color: '', + id: '', + name: '', +}); + +// Account +export interface AccountShape + extends Required< + Omit + > { + emojis: List; + fields: List; + roles: List; + display_name_html: string; + note_emojified: string; + note_plain: string | null; + hidden: boolean; + moved: string | null; +} + +export type Account = RecordOf; + +export const accountDefaultValues: AccountShape = { + acct: '', + avatar: '', + avatar_static: '', + bot: false, + created_at: '', + discoverable: false, + display_name: '', + display_name_html: '', + emojis: List(), + fields: List(), + group: false, + header: '', + header_static: '', + id: '', + last_status_at: '', + locked: false, + noindex: false, + note: '', + note_emojified: '', + note_plain: 'string', + roles: List(), + uri: '', + url: '', + username: '', + followers_count: 0, + following_count: 0, + statuses_count: 0, + hidden: false, + suspended: false, + memorial: false, + limited: false, + moved: null, +}; + +const AccountFactory = ImmutableRecord(accountDefaultValues); + +type EmojiMap = Record; + +function makeEmojiMap(emojis: ApiCustomEmojiJSON[]) { + return emojis.reduce((obj, emoji) => { + obj[`:${emoji.shortcode}:`] = emoji; + return obj; + }, {}); +} + +function createAccountField( + jsonField: ApiAccountFieldJSON, + emojiMap: EmojiMap, +) { + return AccountFieldFactory({ + ...jsonField, + name_emojified: emojify( + escapeTextContentForBrowser(jsonField.name), + emojiMap, + ), + value_emojified: emojify(jsonField.value, emojiMap), + value_plain: unescapeHTML(jsonField.value), + }); +} + +export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) { + const { moved, ...accountJSON } = serverJSON; + + const emojiMap = makeEmojiMap(accountJSON.emojis); + + const displayName = + accountJSON.display_name.trim().length === 0 + ? accountJSON.username + : accountJSON.display_name; + + return AccountFactory({ + ...accountJSON, + moved: moved?.id, + fields: List( + serverJSON.fields.map((field) => createAccountField(field, emojiMap)), + ), + emojis: List(serverJSON.emojis.map((emoji) => CustomEmojiFactory(emoji))), + roles: List(serverJSON.roles?.map((role) => AccountRoleFactory(role))), + display_name_html: emojify( + escapeTextContentForBrowser(displayName), + emojiMap, + ), + note_emojified: emojify(accountJSON.note, emojiMap), + note_plain: unescapeHTML(accountJSON.note), + }); +} diff --git a/app/javascript/flavours/glitch/models/custom_emoji.ts b/app/javascript/flavours/glitch/models/custom_emoji.ts new file mode 100644 index 0000000000..c07ba9929e --- /dev/null +++ b/app/javascript/flavours/glitch/models/custom_emoji.ts @@ -0,0 +1,15 @@ +import type { RecordOf } from 'immutable'; +import { Record } from 'immutable'; + +import type { ApiCustomEmojiJSON } from 'flavours/glitch/api_types/custom_emoji'; + +type CustomEmojiShape = Required; // no changes from server shape +export type CustomEmoji = RecordOf; + +export const CustomEmojiFactory = Record({ + shortcode: '', + static_url: '', + url: '', + category: '', + visible_in_picker: false, +}); diff --git a/app/javascript/flavours/glitch/models/relationship.ts b/app/javascript/flavours/glitch/models/relationship.ts new file mode 100644 index 0000000000..4450cabe9d --- /dev/null +++ b/app/javascript/flavours/glitch/models/relationship.ts @@ -0,0 +1,29 @@ +import type { RecordOf } from 'immutable'; +import { Record } from 'immutable'; + +import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships'; + +type RelationshipShape = Required; // no changes from server shape +export type Relationship = RecordOf; + +const RelationshipFactory = Record({ + blocked_by: false, + blocking: false, + domain_blocking: false, + endorsed: false, + followed_by: false, + following: false, + id: '', + languages: null, + muting_notifications: false, + muting: false, + note: '', + notifying: false, + requested_by: false, + requested: false, + showing_reblogs: false, +}); + +export function createRelationship(attributes: Partial) { + return RelationshipFactory(attributes); +} diff --git a/app/javascript/flavours/glitch/reducers/accounts.js b/app/javascript/flavours/glitch/reducers/accounts.js deleted file mode 100644 index b9e53519b3..0000000000 --- a/app/javascript/flavours/glitch/reducers/accounts.js +++ /dev/null @@ -1,39 +0,0 @@ -import { Map as ImmutableMap, fromJS } from 'immutable'; - -import { ACCOUNT_REVEAL } from 'flavours/glitch/actions/accounts'; -import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from 'flavours/glitch/actions/importer'; - -const initialState = ImmutableMap(); - -const normalizeAccount = (state, account) => { - account = { ...account }; - - delete account.followers_count; - delete account.following_count; - delete account.statuses_count; - - account.hidden = state.getIn([account.id, 'hidden']) === false ? false : account.limited; - - return state.set(account.id, fromJS(account)); -}; - -const normalizeAccounts = (state, accounts) => { - accounts.forEach(account => { - state = normalizeAccount(state, account); - }); - - return state; -}; - -export default function accounts(state = initialState, action) { - switch(action.type) { - case ACCOUNT_IMPORT: - return normalizeAccount(state, action.account); - case ACCOUNTS_IMPORT: - return normalizeAccounts(state, action.accounts); - case ACCOUNT_REVEAL: - return state.setIn([action.id, 'hidden'], false); - default: - return state; - } -} diff --git a/app/javascript/flavours/glitch/reducers/accounts.ts b/app/javascript/flavours/glitch/reducers/accounts.ts new file mode 100644 index 0000000000..c7459d1d5a --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/accounts.ts @@ -0,0 +1,84 @@ +import { Map as ImmutableMap } from 'immutable'; + +import type { Reducer } from 'redux'; + +import { + followAccountSuccess, + unfollowAccountSuccess, + importAccounts, + revealAccount, +} from 'flavours/glitch/actions/accounts_typed'; +import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts'; +import { me } from 'flavours/glitch/initial_state'; +import type { Account } from 'flavours/glitch/models/account'; +import { createAccountFromServerJSON } from 'flavours/glitch/models/account'; + +const initialState = ImmutableMap(); + +const normalizeAccount = ( + state: typeof initialState, + account: ApiAccountJSON, +) => { + return state.set( + account.id, + createAccountFromServerJSON(account).set( + 'hidden', + state.get(account.id)?.hidden === false + ? false + : account.limited || false, + ), + ); +}; + +const normalizeAccounts = ( + state: typeof initialState, + accounts: ApiAccountJSON[], +) => { + accounts.forEach((account) => { + state = normalizeAccount(state, account); + }); + + return state; +}; + +function getCurrentUser() { + if (!me) + throw new Error( + 'No current user (me) defined when calling `accountsReducer`', + ); + + return me; +} + +export const accountsReducer: Reducer = ( + state = initialState, + action, +) => { + if (revealAccount.match(action)) + return state.setIn([action.payload.id, 'hidden'], false); + else if (importAccounts.match(action)) + return normalizeAccounts(state, action.payload.accounts); + else if (followAccountSuccess.match(action)) { + return state + .update( + action.payload.relationship.id, + (account) => account?.update('followers_count', (n) => n + 1), + ) + .update( + getCurrentUser(), + (account) => account?.update('following_count', (n) => n + 1), + ); + } else if (unfollowAccountSuccess.match(action)) + return state + .update( + action.payload.relationship.id, + (account) => + account?.update('followers_count', (n) => Math.max(0, n - 1)), + ) + .update( + getCurrentUser(), + (account) => + account?.update('following_count', (n) => Math.max(0, n - 1)), + ); + else return state; +}; diff --git a/app/javascript/flavours/glitch/reducers/accounts_counters.js b/app/javascript/flavours/glitch/reducers/accounts_counters.js deleted file mode 100644 index 4ef1128700..0000000000 --- a/app/javascript/flavours/glitch/reducers/accounts_counters.js +++ /dev/null @@ -1,49 +0,0 @@ -import { Map as ImmutableMap, fromJS } from 'immutable'; - -import { me } from 'flavours/glitch/initial_state'; - -import { - ACCOUNT_FOLLOW_SUCCESS, - ACCOUNT_UNFOLLOW_SUCCESS, -} from '../actions/accounts'; -import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer'; - -const normalizeAccount = (state, account) => state.set(account.id, fromJS({ - followers_count: account.followers_count, - following_count: account.following_count, - statuses_count: account.statuses_count, -})); - -const normalizeAccounts = (state, accounts) => { - accounts.forEach(account => { - state = normalizeAccount(state, account); - }); - - return state; -}; - -const incrementFollowers = (state, accountId) => - state.updateIn([accountId, 'followers_count'], num => num + 1) - .updateIn([me, 'following_count'], num => num + 1); - -const decrementFollowers = (state, accountId) => - state.updateIn([accountId, 'followers_count'], num => Math.max(0, num - 1)) - .updateIn([me, 'following_count'], num => Math.max(0, num - 1)); - -const initialState = ImmutableMap(); - -export default function accountsCounters(state = initialState, action) { - switch(action.type) { - case ACCOUNT_IMPORT: - return normalizeAccount(state, action.account); - case ACCOUNTS_IMPORT: - return normalizeAccounts(state, action.accounts); - case ACCOUNT_FOLLOW_SUCCESS: - return action.alreadyFollowing ? state : - incrementFollowers(state, action.relationship.id); - case ACCOUNT_UNFOLLOW_SUCCESS: - return decrementFollowers(state, action.relationship.id); - default: - return state; - } -} diff --git a/app/javascript/flavours/glitch/reducers/accounts_map.js b/app/javascript/flavours/glitch/reducers/accounts_map.js index fca0e3ce1e..d5ecad7dbf 100644 --- a/app/javascript/flavours/glitch/reducers/accounts_map.js +++ b/app/javascript/flavours/glitch/reducers/accounts_map.js @@ -1,7 +1,7 @@ import { Map as ImmutableMap } from 'immutable'; import { ACCOUNT_LOOKUP_FAIL } from '../actions/accounts'; -import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer'; +import { importAccounts } from '../actions/accounts_typed'; export const normalizeForLookup = str => str.toLowerCase(); @@ -11,10 +11,8 @@ export default function accountsMap(state = initialState, action) { switch(action.type) { case ACCOUNT_LOOKUP_FAIL: return action.error?.response?.status === 404 ? state.set(normalizeForLookup(action.acct), null) : state; - case ACCOUNT_IMPORT: - return state.set(normalizeForLookup(action.account.acct), action.account.id); - case ACCOUNTS_IMPORT: - return state.withMutations(map => action.accounts.forEach(account => map.set(normalizeForLookup(account.acct), account.id))); + case importAccounts.type: + return state.withMutations(map => action.payload.accounts.forEach(account => map.set(normalizeForLookup(account.acct), account.id))); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/contexts.js b/app/javascript/flavours/glitch/reducers/contexts.js index 32e194dd42..f7d7419a4e 100644 --- a/app/javascript/flavours/glitch/reducers/contexts.js +++ b/app/javascript/flavours/glitch/reducers/contexts.js @@ -1,8 +1,8 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { - ACCOUNT_BLOCK_SUCCESS, - ACCOUNT_MUTE_SUCCESS, + blockAccountSuccess, + muteAccountSuccess, } from '../actions/accounts'; import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; import { TIMELINE_DELETE, TIMELINE_UPDATE } from '../actions/timelines'; @@ -92,9 +92,9 @@ const updateContext = (state, status) => { export default function replies(state = initialState, action) { switch(action.type) { - case ACCOUNT_BLOCK_SUCCESS: - case ACCOUNT_MUTE_SUCCESS: - return filterContexts(state, action.relationship, action.statuses); + case blockAccountSuccess.type: + case muteAccountSuccess.type: + return filterContexts(state, action.payload.relationship, action.payload.statuses); case CONTEXT_FETCH_SUCCESS: return normalizeContext(state, action.id, action.ancestors, action.descendants); case TIMELINE_DELETE: diff --git a/app/javascript/flavours/glitch/reducers/conversations.js b/app/javascript/flavours/glitch/reducers/conversations.js index 3bef9a8419..fe2ec78ddf 100644 --- a/app/javascript/flavours/glitch/reducers/conversations.js +++ b/app/javascript/flavours/glitch/reducers/conversations.js @@ -1,7 +1,7 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; -import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'flavours/glitch/actions/accounts'; -import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks'; +import { blockAccountSuccess, muteAccountSuccess } from 'flavours/glitch/actions/accounts'; +import { blockDomainSuccess } from 'flavours/glitch/actions/domain_blocks'; import { CONVERSATIONS_MOUNT, @@ -105,11 +105,11 @@ export default function conversations(state = initialState, action) { return item; })); - case ACCOUNT_BLOCK_SUCCESS: - case ACCOUNT_MUTE_SUCCESS: - return filterConversations(state, [action.relationship.id]); - case DOMAIN_BLOCK_SUCCESS: - return filterConversations(state, action.accounts); + case blockAccountSuccess.type: + case muteAccountSuccess.type: + return filterConversations(state, [action.payload.relationship.id]); + case blockDomainSuccess.type: + return filterConversations(state, action.payload.accounts); case CONVERSATIONS_DELETE_SUCCESS: return state.update('items', list => list.filterNot(item => item.get('id') === action.id)); default: diff --git a/app/javascript/flavours/glitch/reducers/domain_lists.js b/app/javascript/flavours/glitch/reducers/domain_lists.js index 8cdd3ba376..5f63c77f5d 100644 --- a/app/javascript/flavours/glitch/reducers/domain_lists.js +++ b/app/javascript/flavours/glitch/reducers/domain_lists.js @@ -3,7 +3,7 @@ import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutabl import { DOMAIN_BLOCKS_FETCH_SUCCESS, DOMAIN_BLOCKS_EXPAND_SUCCESS, - DOMAIN_UNBLOCK_SUCCESS, + unblockDomainSuccess } from '../actions/domain_blocks'; const initialState = ImmutableMap({ @@ -18,8 +18,8 @@ export default function domainLists(state = initialState, action) { return state.setIn(['blocks', 'items'], ImmutableOrderedSet(action.domains)).setIn(['blocks', 'next'], action.next); case DOMAIN_BLOCKS_EXPAND_SUCCESS: return state.updateIn(['blocks', 'items'], set => set.union(action.domains)).setIn(['blocks', 'next'], action.next); - case DOMAIN_UNBLOCK_SUCCESS: - return state.updateIn(['blocks', 'items'], set => set.delete(action.domain)); + case unblockDomainSuccess.type: + return state.updateIn(['blocks', 'items'], set => set.delete(action.payload.domain)); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/index.ts b/app/javascript/flavours/glitch/reducers/index.ts index 0d6f0a5826..4775c076e7 100644 --- a/app/javascript/flavours/glitch/reducers/index.ts +++ b/app/javascript/flavours/glitch/reducers/index.ts @@ -3,8 +3,7 @@ import { Record as ImmutableRecord } from 'immutable'; import { loadingBarReducer } from 'react-redux-loading-bar'; import { combineReducers } from 'redux-immutable'; -import accounts from './accounts'; -import accounts_counters from './accounts_counters'; +import { accountsReducer } from './accounts'; import accounts_map from './accounts_map'; import alerts from './alerts'; import announcements from './announcements'; @@ -34,7 +33,7 @@ import picture_in_picture from './picture_in_picture'; import pinnedAccountsEditor from './pinned_accounts_editor'; import polls from './polls'; import push_notifications from './push_notifications'; -import relationships from './relationships'; +import { relationshipsReducer } from './relationships'; import search from './search'; import server from './server'; import settings from './settings'; @@ -57,11 +56,10 @@ const reducers = { user_lists, domain_lists, status_lists, - accounts, - accounts_counters, + accounts: accountsReducer, accounts_map, statuses, - relationships, + relationships: relationshipsReducer, settings, local_settings, push_notifications, diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js index 7bb11459ca..37aed87348 100644 --- a/app/javascript/flavours/glitch/reducers/notifications.js +++ b/app/javascript/flavours/glitch/reducers/notifications.js @@ -1,12 +1,12 @@ import { fromJS, Map as ImmutableMap, List as ImmutableList } from 'immutable'; -import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks'; +import { blockDomainSuccess } from 'flavours/glitch/actions/domain_blocks'; import { - ACCOUNT_BLOCK_SUCCESS, - ACCOUNT_MUTE_SUCCESS, - FOLLOW_REQUEST_AUTHORIZE_SUCCESS, - FOLLOW_REQUEST_REJECT_SUCCESS, + authorizeFollowRequestSuccess, + blockAccountSuccess, + muteAccountSuccess, + rejectFollowRequestSuccess, } from '../actions/accounts'; import { MARKERS_FETCH_SUCCESS, @@ -15,7 +15,7 @@ import { NOTIFICATIONS_MOUNT, NOTIFICATIONS_UNMOUNT, NOTIFICATIONS_SET_VISIBILITY, - NOTIFICATIONS_UPDATE, + notificationsUpdate, NOTIFICATIONS_EXPAND_SUCCESS, NOTIFICATIONS_EXPAND_REQUEST, NOTIFICATIONS_EXPAND_FAIL, @@ -316,19 +316,19 @@ export default function notifications(state = initialState, action) { return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', true); case NOTIFICATIONS_SCROLL_TOP: return updateTop(state, action.top); - case NOTIFICATIONS_UPDATE: - return normalizeNotification(state, action.notification, action.usePendingItems); + case notificationsUpdate.type: + return normalizeNotification(state, action.payload.notification, action.payload.usePendingItems); case NOTIFICATIONS_EXPAND_SUCCESS: return expandNormalizedNotifications(state, action.notifications, action.next, action.isLoadingMore, action.isLoadingRecent, action.usePendingItems); - case ACCOUNT_BLOCK_SUCCESS: - return filterNotifications(state, [action.relationship.id]); - case ACCOUNT_MUTE_SUCCESS: - return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state; - case DOMAIN_BLOCK_SUCCESS: - return filterNotifications(state, action.accounts); - case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: - case FOLLOW_REQUEST_REJECT_SUCCESS: - return filterNotifications(state, [action.id], 'follow_request'); + case blockAccountSuccess.type: + return filterNotifications(state, [action.payload.relationship.id]); + case muteAccountSuccess.type: + return action.payload.relationship.muting_notifications ? filterNotifications(state, [action.payload.relationship.id]) : state; + case blockDomainSuccess.type: + return filterNotifications(state, action.payload.accounts); + case authorizeFollowRequestSuccess.type: + case rejectFollowRequestSuccess.type: + return filterNotifications(state, [action.payload.id], 'follow_request'); case NOTIFICATIONS_CLEAR: return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false); case TIMELINE_DELETE: diff --git a/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js b/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js index e672c34b34..352db5733b 100644 --- a/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js +++ b/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js @@ -8,8 +8,8 @@ import { PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS, PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR, PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE, - ACCOUNT_PIN_SUCCESS, - ACCOUNT_UNPIN_SUCCESS, + pinAccountSuccess, + unpinAccountSuccess, } from '../actions/accounts'; const initialState = ImmutableMap({ @@ -48,10 +48,10 @@ export default function listEditorReducer(state = initialState, action) { map.set('items', ImmutableList()); map.set('value', ''); })); - case ACCOUNT_PIN_SUCCESS: - return state.updateIn(['accounts', 'items'], list => list.unshift(action.relationship.id)); - case ACCOUNT_UNPIN_SUCCESS: - return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.relationship.id)); + case pinAccountSuccess.type: + return state.updateIn(['accounts', 'items'], list => list.unshift(action.payload.relationship.id)); + case unpinAccountSuccess.type: + return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.payload.relationship.id)); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/relationships.js b/app/javascript/flavours/glitch/reducers/relationships.js deleted file mode 100644 index 32b4b4f371..0000000000 --- a/app/javascript/flavours/glitch/reducers/relationships.js +++ /dev/null @@ -1,88 +0,0 @@ -import { Map as ImmutableMap, fromJS } from 'immutable'; - -import { - submitAccountNote, -} from '../actions/account_notes'; -import { - ACCOUNT_FOLLOW_SUCCESS, - ACCOUNT_FOLLOW_REQUEST, - ACCOUNT_FOLLOW_FAIL, - ACCOUNT_UNFOLLOW_SUCCESS, - ACCOUNT_UNFOLLOW_REQUEST, - ACCOUNT_UNFOLLOW_FAIL, - ACCOUNT_BLOCK_SUCCESS, - ACCOUNT_UNBLOCK_SUCCESS, - ACCOUNT_MUTE_SUCCESS, - ACCOUNT_UNMUTE_SUCCESS, - ACCOUNT_PIN_SUCCESS, - ACCOUNT_UNPIN_SUCCESS, - RELATIONSHIPS_FETCH_SUCCESS, - FOLLOW_REQUEST_AUTHORIZE_SUCCESS, - FOLLOW_REQUEST_REJECT_SUCCESS, -} from '../actions/accounts'; -import { - DOMAIN_BLOCK_SUCCESS, - DOMAIN_UNBLOCK_SUCCESS, -} from '../actions/domain_blocks'; -import { - NOTIFICATIONS_UPDATE, -} from '../actions/notifications'; - - -const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship)); - -const normalizeRelationships = (state, relationships) => { - relationships.forEach(relationship => { - state = normalizeRelationship(state, relationship); - }); - - return state; -}; - -const setDomainBlocking = (state, accounts, blocking) => { - return state.withMutations(map => { - accounts.forEach(id => { - map.setIn([id, 'domain_blocking'], blocking); - }); - }); -}; - -const initialState = ImmutableMap(); - -export default function relationships(state = initialState, action) { - switch(action.type) { - case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: - return state.setIn([action.id, 'followed_by'], true).setIn([action.id, 'requested_by'], false); - case FOLLOW_REQUEST_REJECT_SUCCESS: - return state.setIn([action.id, 'followed_by'], false).setIn([action.id, 'requested_by'], false); - case NOTIFICATIONS_UPDATE: - return action.notification.type === 'follow_request' ? state.setIn([action.notification.account.id, 'requested_by'], true) : state; - case ACCOUNT_FOLLOW_REQUEST: - return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true); - case ACCOUNT_FOLLOW_FAIL: - return state.setIn([action.id, action.locked ? 'requested' : 'following'], false); - case ACCOUNT_UNFOLLOW_REQUEST: - return state.setIn([action.id, 'following'], false); - case ACCOUNT_UNFOLLOW_FAIL: - return state.setIn([action.id, 'following'], true); - case ACCOUNT_FOLLOW_SUCCESS: - case ACCOUNT_UNFOLLOW_SUCCESS: - case ACCOUNT_BLOCK_SUCCESS: - case ACCOUNT_UNBLOCK_SUCCESS: - case ACCOUNT_MUTE_SUCCESS: - case ACCOUNT_UNMUTE_SUCCESS: - case ACCOUNT_PIN_SUCCESS: - case ACCOUNT_UNPIN_SUCCESS: - return normalizeRelationship(state, action.relationship); - case RELATIONSHIPS_FETCH_SUCCESS: - return normalizeRelationships(state, action.relationships); - case submitAccountNote.fulfilled: - return normalizeRelationship(state, action.payload.relationship); - case DOMAIN_BLOCK_SUCCESS: - return setDomainBlocking(state, action.accounts, true); - case DOMAIN_UNBLOCK_SUCCESS: - return setDomainBlocking(state, action.accounts, false); - default: - return state; - } -} diff --git a/app/javascript/flavours/glitch/reducers/relationships.ts b/app/javascript/flavours/glitch/reducers/relationships.ts new file mode 100644 index 0000000000..5d75529475 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/relationships.ts @@ -0,0 +1,123 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { isFulfilled } from '@reduxjs/toolkit'; +import type { Reducer } from 'redux'; + +import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships'; +import type { Account } from 'flavours/glitch/models/account'; +import { createRelationship } from 'flavours/glitch/models/relationship'; +import type { Relationship } from 'flavours/glitch/models/relationship'; + +import { submitAccountNote } from '../actions/account_notes'; +import { + followAccountSuccess, + unfollowAccountSuccess, + authorizeFollowRequestSuccess, + rejectFollowRequestSuccess, + followAccountRequest, + followAccountFail, + unfollowAccountRequest, + unfollowAccountFail, + blockAccountSuccess, + unblockAccountSuccess, + muteAccountSuccess, + unmuteAccountSuccess, + pinAccountSuccess, + unpinAccountSuccess, + fetchRelationshipsSuccess, +} from '../actions/accounts_typed'; +import { + blockDomainSuccess, + unblockDomainSuccess, +} from '../actions/domain_blocks_typed'; +import { notificationsUpdate } from '../actions/notifications_typed'; + +const initialState = ImmutableMap(); +type State = typeof initialState; + +const normalizeRelationship = ( + state: State, + relationship: ApiRelationshipJSON, +) => state.set(relationship.id, createRelationship(relationship)); + +const normalizeRelationships = ( + state: State, + relationships: ApiRelationshipJSON[], +) => { + relationships.forEach((relationship) => { + state = normalizeRelationship(state, relationship); + }); + + return state; +}; + +const setDomainBlocking = ( + state: State, + accounts: Account[], + blocking: boolean, +) => { + return state.withMutations((map) => { + accounts.forEach((id) => { + map.setIn([id, 'domain_blocking'], blocking); + }); + }); +}; + +export const relationshipsReducer: Reducer = ( + state = initialState, + action, +) => { + if (authorizeFollowRequestSuccess.match(action)) + return state + .setIn([action.payload.id, 'followed_by'], true) + .setIn([action.payload.id, 'requested_by'], false); + else if (rejectFollowRequestSuccess.match(action)) + return state + .setIn([action.payload.id, 'followed_by'], false) + .setIn([action.payload.id, 'requested_by'], false); + else if (notificationsUpdate.match(action)) + return action.payload.notification.type === 'follow_request' + ? state.setIn( + [action.payload.notification.account.id, 'requested_by'], + true, + ) + : state; + else if (followAccountRequest.match(action)) + return state.getIn([action.payload.id, 'following']) + ? state + : state.setIn( + [ + action.payload.id, + action.payload.locked ? 'requested' : 'following', + ], + true, + ); + else if (followAccountFail.match(action)) + return state.setIn( + [action.payload.id, action.payload.locked ? 'requested' : 'following'], + false, + ); + else if (unfollowAccountRequest.match(action)) + return state.setIn([action.payload.id, 'following'], false); + else if (unfollowAccountFail.match(action)) + return state.setIn([action.payload.id, 'following'], true); + else if ( + followAccountSuccess.match(action) || + unfollowAccountSuccess.match(action) || + blockAccountSuccess.match(action) || + unblockAccountSuccess.match(action) || + muteAccountSuccess.match(action) || + unmuteAccountSuccess.match(action) || + pinAccountSuccess.match(action) || + unpinAccountSuccess.match(action) || + isFulfilled(submitAccountNote)(action) + ) + return normalizeRelationship(state, action.payload.relationship); + else if (fetchRelationshipsSuccess.match(action)) + return normalizeRelationships(state, action.payload.relationships); + else if (blockDomainSuccess.match(action)) + return setDomainBlocking(state, action.payload.accounts, true); + else if (unblockDomainSuccess.match(action)) + return setDomainBlocking(state, action.payload.accounts, false); + else return state; +}; diff --git a/app/javascript/flavours/glitch/reducers/status_lists.js b/app/javascript/flavours/glitch/reducers/status_lists.js index 41cc07341c..6cb6a937bb 100644 --- a/app/javascript/flavours/glitch/reducers/status_lists.js +++ b/app/javascript/flavours/glitch/reducers/status_lists.js @@ -1,8 +1,8 @@ import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; import { - ACCOUNT_BLOCK_SUCCESS, - ACCOUNT_MUTE_SUCCESS, + blockAccountSuccess, + muteAccountSuccess, } from '../actions/accounts'; import { BOOKMARKED_STATUSES_FETCH_REQUEST, @@ -142,9 +142,9 @@ export default function statusLists(state = initialState, action) { return prependOneToList(state, 'pins', action.status); case UNPIN_SUCCESS: return removeOneFromList(state, 'pins', action.status); - case ACCOUNT_BLOCK_SUCCESS: - case ACCOUNT_MUTE_SUCCESS: - return state.updateIn(['trending', 'items'], ImmutableOrderedSet(), list => list.filterNot(statusId => action.statuses.getIn([statusId, 'account']) === action.relationship.id)); + case blockAccountSuccess.type: + case muteAccountSuccess.type: + return state.updateIn(['trending', 'items'], ImmutableOrderedSet(), list => list.filterNot(statusId => action.payload.statuses.getIn([statusId, 'account']) === action.payload.relationship.id)); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/suggestions.js b/app/javascript/flavours/glitch/reducers/suggestions.js index c6b56353ed..03a7c80cf5 100644 --- a/app/javascript/flavours/glitch/reducers/suggestions.js +++ b/app/javascript/flavours/glitch/reducers/suggestions.js @@ -1,7 +1,7 @@ import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; -import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'flavours/glitch/actions/accounts'; -import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks'; +import { blockAccountSuccess, muteAccountSuccess } from 'flavours/glitch/actions/accounts'; +import { blockDomainSuccess } from 'flavours/glitch/actions/domain_blocks'; import { SUGGESTIONS_FETCH_REQUEST, @@ -29,11 +29,11 @@ export default function suggestionsReducer(state = initialState, action) { return state.set('isLoading', false); case SUGGESTIONS_DISMISS: return state.update('items', list => list.filterNot(x => x.account === action.id)); - case ACCOUNT_BLOCK_SUCCESS: - case ACCOUNT_MUTE_SUCCESS: - return state.update('items', list => list.filterNot(x => x.account === action.relationship.id)); - case DOMAIN_BLOCK_SUCCESS: - return state.update('items', list => list.filterNot(x => action.accounts.includes(x.account))); + case blockAccountSuccess.type: + case muteAccountSuccess.type: + return state.update('items', list => list.filterNot(x => x.account === action.payload.relationship.id)); + case blockDomainSuccess.type: + return state.update('items', list => list.filterNot(x => action.payload.accounts.includes(x.account))); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js index 69f28d6849..6ff83aa7f0 100644 --- a/app/javascript/flavours/glitch/reducers/timelines.js +++ b/app/javascript/flavours/glitch/reducers/timelines.js @@ -1,9 +1,9 @@ import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import { - ACCOUNT_BLOCK_SUCCESS, - ACCOUNT_MUTE_SUCCESS, - ACCOUNT_UNFOLLOW_SUCCESS, + blockAccountSuccess, + muteAccountSuccess, + unfollowAccountSuccess } from '../actions/accounts'; import { TIMELINE_UPDATE, @@ -206,11 +206,11 @@ export default function timelines(state = initialState, action) { return deleteStatus(state, action.id, action.references, action.reblogOf); case TIMELINE_CLEAR: return clearTimeline(state, action.timeline); - case ACCOUNT_BLOCK_SUCCESS: - case ACCOUNT_MUTE_SUCCESS: - return filterTimelines(state, action.relationship, action.statuses); - case ACCOUNT_UNFOLLOW_SUCCESS: - return filterTimeline('home', state, action.relationship, action.statuses); + case blockAccountSuccess.type: + case muteAccountSuccess.type: + return filterTimelines(state, action.payload.relationship, action.payload.statuses); + case unfollowAccountSuccess.type: + return filterTimeline('home', state, action.payload.relationship, action.payload.statuses); case TIMELINE_SCROLL_TOP: return updateTop(state, action.timeline, action.top); case TIMELINE_CONNECT: diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js index 76786b4220..3eb80da437 100644 --- a/app/javascript/flavours/glitch/reducers/user_lists.js +++ b/app/javascript/flavours/glitch/reducers/user_lists.js @@ -33,8 +33,8 @@ import { FOLLOW_REQUESTS_EXPAND_REQUEST, FOLLOW_REQUESTS_EXPAND_SUCCESS, FOLLOW_REQUESTS_EXPAND_FAIL, - FOLLOW_REQUEST_AUTHORIZE_SUCCESS, - FOLLOW_REQUEST_REJECT_SUCCESS, + authorizeFollowRequestSuccess, + rejectFollowRequestSuccess, } from '../actions/accounts'; import { BLOCKS_FETCH_REQUEST, @@ -66,11 +66,7 @@ import { MUTES_EXPAND_SUCCESS, MUTES_EXPAND_FAIL, } from '../actions/mutes'; -import { - NOTIFICATIONS_UPDATE, -} from '../actions/notifications'; - - +import { notificationsUpdate } from '../actions/notifications'; const initialListState = ImmutableMap({ next: null, @@ -163,8 +159,8 @@ export default function userLists(state = initialState, action) { case FAVOURITES_FETCH_FAIL: case FAVOURITES_EXPAND_FAIL: return state.setIn(['favourited_by', action.id, 'isLoading'], false); - case NOTIFICATIONS_UPDATE: - return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state; + case notificationsUpdate.type: + return action.payload.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.payload.notification) : state; case FOLLOW_REQUESTS_FETCH_SUCCESS: return normalizeList(state, ['follow_requests'], action.accounts, action.next); case FOLLOW_REQUESTS_EXPAND_SUCCESS: @@ -175,9 +171,9 @@ export default function userLists(state = initialState, action) { case FOLLOW_REQUESTS_FETCH_FAIL: case FOLLOW_REQUESTS_EXPAND_FAIL: return state.setIn(['follow_requests', 'isLoading'], false); - case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: - case FOLLOW_REQUEST_REJECT_SUCCESS: - return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id)); + case authorizeFollowRequestSuccess.type: + case rejectFollowRequestSuccess.type: + return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.payload.id)); case BLOCKS_FETCH_SUCCESS: return normalizeList(state, ['blocks'], action.accounts, action.next); case BLOCKS_EXPAND_SUCCESS: diff --git a/app/javascript/flavours/glitch/selectors/accounts.ts b/app/javascript/flavours/glitch/selectors/accounts.ts new file mode 100644 index 0000000000..9b375c5b95 --- /dev/null +++ b/app/javascript/flavours/glitch/selectors/accounts.ts @@ -0,0 +1,47 @@ +import { Record as ImmutableRecord } from 'immutable'; +import { createSelector } from 'reselect'; + +import { accountDefaultValues } from 'flavours/glitch/models/account'; +import type { Account, AccountShape } from 'flavours/glitch/models/account'; +import type { Relationship } from 'flavours/glitch/models/relationship'; +import type { RootState } from 'flavours/glitch/store'; + +const getAccountBase = (state: RootState, id: string) => + state.accounts.get(id, null); + +const getAccountRelationship = (state: RootState, id: string) => + state.relationships.get(id, null); + +const getAccountMoved = (state: RootState, id: string) => { + const movedToId = state.accounts.get(id)?.moved; + + if (!movedToId) return undefined; + + return state.accounts.get(movedToId); +}; + +interface FullAccountShape extends Omit { + relationship: Relationship | null; + moved: Account | null; +} + +const FullAccountFactory = ImmutableRecord({ + ...accountDefaultValues, + moved: null, + relationship: null, +}); + +export function makeGetAccount() { + return createSelector( + [getAccountBase, getAccountRelationship, getAccountMoved], + (base, relationship, moved) => { + if (base === null) { + return null; + } + + return FullAccountFactory(base) + .set('relationship', relationship) + .set('moved', moved ?? null); + }, + ); +} diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js index 943804eed6..98100e1f1b 100644 --- a/app/javascript/flavours/glitch/selectors/index.js +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -5,23 +5,7 @@ import { toServerSideType } from 'flavours/glitch/utils/filters'; import { me } from '../initial_state'; -const getAccountBase = (state, id) => state.getIn(['accounts', id], null); -const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null); -const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null); -const getAccountMoved = (state, id) => state.getIn(['accounts', state.getIn(['accounts', id, 'moved'])]); - -export const makeGetAccount = () => { - return createSelector([getAccountBase, getAccountCounters, getAccountRelationship, getAccountMoved], (base, counters, relationship, moved) => { - if (base === null) { - return null; - } - - return base.merge(counters).withMutations(map => { - map.set('relationship', relationship); - map.set('moved', moved); - }); - }); -}; +export { makeGetAccount } from "./accounts"; const getFilters = (state, { contextType }) => { if (!contextType) return null; diff --git a/app/javascript/flavours/glitch/store/middlewares/loading_bar.ts b/app/javascript/flavours/glitch/store/middlewares/loading_bar.ts index 5fe8000731..83056ee49f 100644 --- a/app/javascript/flavours/glitch/store/middlewares/loading_bar.ts +++ b/app/javascript/flavours/glitch/store/middlewares/loading_bar.ts @@ -1,3 +1,9 @@ +import { + isAsyncThunkAction, + isPending as isThunkActionPending, + isFulfilled as isThunkActionFulfilled, + isRejected as isThunkActionRejected, +} from '@reduxjs/toolkit'; import { showLoading, hideLoading } from 'react-redux-loading-bar'; import type { AnyAction, Middleware } from 'redux'; @@ -21,25 +27,43 @@ export const loadingBarMiddleware = ( return ({ dispatch }) => (next) => (action: AnyAction) => { - if (action.type && !action.skipLoading) { + let isPending = false; + let isFulfilled = false; + let isRejected = false; + + if ( + isAsyncThunkAction(action) + // TODO: once we get the first use-case for it, add a check for skipLoading + ) { + if (isThunkActionPending(action)) isPending = true; + else if (isThunkActionFulfilled(action)) isFulfilled = true; + else if (isThunkActionRejected(action)) isRejected = true; + } else if ( + action.type && + !action.skipLoading && + typeof action.type === 'string' + ) { const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes; - const isPending = new RegExp(`${PENDING}$`, 'g'); - const isFulfilled = new RegExp(`${FULFILLED}$`, 'g'); - const isRejected = new RegExp(`${REJECTED}$`, 'g'); + const isPendingRegexp = new RegExp(`${PENDING}$`, 'g'); + const isFulfilledRegexp = new RegExp(`${FULFILLED}$`, 'g'); + const isRejectedRegexp = new RegExp(`${REJECTED}$`, 'g'); - if (typeof action.type === 'string') { - if (action.type.match(isPending)) { - dispatch(showLoading()); - } else if ( - action.type.match(isFulfilled) ?? - action.type.match(isRejected) - ) { - dispatch(hideLoading()); - } + if (action.type.match(isPendingRegexp)) { + isPending = true; + } else if (action.type.match(isFulfilledRegexp)) { + isFulfilled = true; + } else if (action.type.match(isRejectedRegexp)) { + isRejected = true; } } + if (isPending) { + dispatch(showLoading()); + } else if (isFulfilled || isRejected) { + dispatch(hideLoading()); + } + return next(action); }; }; diff --git a/app/javascript/flavours/glitch/store/store.ts b/app/javascript/flavours/glitch/store/store.ts index 6350885680..9f43f58a43 100644 --- a/app/javascript/flavours/glitch/store/store.ts +++ b/app/javascript/flavours/glitch/store/store.ts @@ -35,6 +35,5 @@ export const store = configureStore({ // Infer the `RootState` and `AppDispatch` types from the store itself export type RootState = ReturnType; -// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} export type AppDispatch = typeof store.dispatch; export type GetState = typeof store.getState; 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/flavours/glitch/types/resources.ts b/app/javascript/flavours/glitch/types/resources.ts deleted file mode 100644 index f3901ad150..0000000000 --- a/app/javascript/flavours/glitch/types/resources.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Record } from 'immutable'; - -type CustomEmoji = Record<{ - shortcode: string; - static_url: string; - url: string; -}>; - -type AccountField = Record<{ - name: string; - value: string; - verified_at: string | null; -}>; - -interface AccountApiResponseValues { - acct: string; - avatar: string; - avatar_static: string; - bot: boolean; - created_at: string; - discoverable: boolean; - display_name: string; - emojis: CustomEmoji[]; - fields: AccountField[]; - followers_count: number; - following_count: number; - group: boolean; - header: string; - header_static: string; - id: string; - last_status_at: string; - locked: boolean; - note: string; - statuses_count: number; - url: string; - uri: string; - username: string; -} - -type NormalizedAccountField = Record<{ - name_emojified: string; - value_emojified: string; - value_plain: string; -}>; - -interface NormalizedAccountValues { - display_name_html: string; - fields: NormalizedAccountField[]; - note_emojified: string; - note_plain: string; -} - -export type Account = Record< - AccountApiResponseValues & NormalizedAccountValues ->; 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 ( + <> + + +
+
+

+

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