diff --git a/.devcontainer/codespaces/devcontainer.json b/.devcontainer/codespaces/devcontainer.json index d2358657f6d664..8acffec8259867 100644 --- a/.devcontainer/codespaces/devcontainer.json +++ b/.devcontainer/codespaces/devcontainer.json @@ -39,7 +39,7 @@ }, "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", - "postCreateCommand": "bin/setup", + "postCreateCommand": "COREPACK_ENABLE_DOWNLOAD_PROMPT=0 bin/setup", "waitFor": "postCreateCommand", "customizations": { diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml index 6717853304a6c3..18559a62b69021 100644 --- a/.github/workflows/crowdin-upload.yml +++ b/.github/workflows/crowdin-upload.yml @@ -19,6 +19,7 @@ on: jobs: upload-translations: runs-on: ubuntu-latest + if: github.repository == 'mastodon/mastodon' steps: - name: Checkout diff --git a/.nvmrc b/.nvmrc index cecb93628953cd..1bdd901e66fd02 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.15 +20.16 diff --git a/Dockerfile b/Dockerfile index 758db9bcc90b56..a0af1eda6b65fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# syntax=docker/dockerfile:1.8 +# syntax=docker/dockerfile:1.9 # This file is designed for production server deployment, not local development work # For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker @@ -211,7 +211,7 @@ FROM build AS ffmpeg # ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"] # renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg -ARG FFMPEG_VERSION=7.0.1 +ARG FFMPEG_VERSION=7.0.2 # ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"] ARG FFMPEG_URL=https://ffmpeg.org/releases diff --git a/Gemfile b/Gemfile index ef52d50cac128d..cf930dadb6eb7f 100644 --- a/Gemfile +++ b/Gemfile @@ -16,7 +16,7 @@ gem 'pghero' gem 'aws-sdk-s3', '~> 1.123', require: false gem 'blurhash', '~> 0.1' -gem 'fog-core', '<= 2.4.0' +gem 'fog-core', '<= 2.5.0' gem 'fog-openstack', '~> 1.0', require: false gem 'kt-paperclip', '~> 7.2' gem 'md-paperclip-azure', '~> 2.2', require: false @@ -88,7 +88,7 @@ gem 'sidekiq-unique-jobs', '~> 7.1' gem 'simple_form', '~> 5.2' gem 'simple-navigation', '~> 4.4' gem 'stoplight', '~> 4.1' -gem 'strong_migrations', '1.8.0' +gem 'strong_migrations' gem 'tty-prompt', '~> 0.23', require: false gem 'twitter-text', '~> 3.1.0' gem 'tzinfo-data', '~> 1.2023' @@ -100,7 +100,7 @@ gem 'json-ld' gem 'json-ld-preloaded', '~> 3.2' gem 'rdf-normalize', '~> 0.5' -gem 'opentelemetry-api', '~> 1.2.5' +gem 'opentelemetry-api', '~> 1.3.0' group :opentelemetry do gem 'opentelemetry-exporter-otlp', '~> 0.28.0', require: false diff --git a/Gemfile.lock b/Gemfile.lock index c9781a40500cb0..e4d787eb6463d6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -100,8 +100,8 @@ GEM attr_required (1.0.2) awrence (1.2.1) aws-eventstream (1.3.0) - aws-partitions (1.950.0) - aws-sdk-core (3.201.0) + aws-partitions (1.961.0) + aws-sdk-core (3.201.3) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.8) @@ -109,11 +109,11 @@ GEM aws-sdk-kms (1.88.0) aws-sdk-core (~> 3, >= 3.201.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.156.0) + aws-sdk-s3 (1.157.0) aws-sdk-core (~> 3, >= 3.201.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.8.0) + aws-sigv4 (1.9.1) aws-eventstream (~> 1, >= 1.0.2) azure-storage-blob (2.0.3) azure-storage-common (~> 2.0) @@ -135,7 +135,7 @@ GEM binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) blurhash (0.1.7) - bootsnap (1.18.3) + bootsnap (1.18.4) msgpack (~> 1.2) brakeman (6.1.2) racc @@ -222,14 +222,14 @@ GEM elasticsearch-transport (7.17.10) faraday (>= 1, < 3) multi_json - email_spec (2.2.2) + email_spec (2.3.0) htmlentities (~> 4.3.3) - launchy (~> 2.1) + launchy (>= 2.1, < 4.0) mail (~> 2.7) erubi (1.13.0) et-orbi (1.2.11) tzinfo - excon (0.110.0) + excon (0.111.0) fabrication (2.31.0) faker (3.4.2) i18n (>= 1.8.11, < 2) @@ -269,7 +269,7 @@ GEM flatware-rspec (2.3.2) flatware (= 2.3.2) rspec (>= 3.6) - fog-core (2.4.0) + fog-core (2.5.0) builder excon (~> 0.71) formatador (>= 0.2, < 2.0) @@ -289,7 +289,7 @@ GEM ruby-progressbar (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - google-protobuf (3.25.3) + google-protobuf (3.25.4) googleapis-common-protos-types (1.14.0) google-protobuf (~> 3.18) haml (6.3.0) @@ -357,13 +357,14 @@ GEM aes_key_wrap bindata httpclient - json-ld (3.3.1) + json-ld (3.3.2) htmlentities (~> 4.3) json-canonicalization (~> 1.0) link_header (~> 0.0, >= 0.0.8) multi_json (~> 1.15) rack (>= 2.2, < 4) rdf (~> 3.3) + rexml (~> 3.2) json-ld-preloaded (3.3.0) json-ld (~> 3.3) rdf (~> 3.3) @@ -428,7 +429,7 @@ GEM memory_profiler (1.0.2) mime-types (3.5.2) mime-types-data (~> 3.2015) - mime-types-data (3.2024.0604) + mime-types-data (3.2024.0702) mini_mime (1.1.5) mini_portile2 (2.8.7) minitest (5.24.1) @@ -440,7 +441,7 @@ GEM uri net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.12) + net-imap (0.4.14) date net-protocol net-ldap (0.19.0) @@ -451,7 +452,7 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.3) - nokogiri (1.16.6) + nokogiri (1.16.7) mini_portile2 (~> 2.8.2) racc (~> 1.4) nsa (0.3.0) @@ -492,10 +493,10 @@ GEM openssl (3.2.0) openssl-signature_algorithm (1.3.0) openssl (> 2.0) - opentelemetry-api (1.2.5) + opentelemetry-api (1.3.0) opentelemetry-common (0.20.1) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.28.0) + opentelemetry-exporter-otlp (0.28.1) google-protobuf (>= 3.18) googleapis-common-protos-types (~> 1.3) opentelemetry-api (~> 1.1) @@ -512,14 +513,14 @@ GEM opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-action_view (0.7.0) + opentelemetry-instrumentation-action_view (0.7.1) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-active_support (~> 0.1) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_job (0.7.2) + opentelemetry-instrumentation-active_job (0.7.4) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_model_serializers (0.20.1) + opentelemetry-instrumentation-active_model_serializers (0.20.2) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-active_record (0.7.2) @@ -531,32 +532,32 @@ GEM opentelemetry-instrumentation-base (0.22.3) opentelemetry-api (~> 1.0) opentelemetry-registry (~> 0.1) - opentelemetry-instrumentation-concurrent_ruby (0.21.3) + opentelemetry-instrumentation-concurrent_ruby (0.21.4) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-excon (0.22.3) + opentelemetry-instrumentation-excon (0.22.4) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-faraday (0.24.5) + opentelemetry-instrumentation-faraday (0.24.6) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http (0.23.3) + opentelemetry-instrumentation-http (0.23.4) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http_client (0.22.6) + opentelemetry-instrumentation-http_client (0.22.7) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-net_http (0.22.6) + opentelemetry-instrumentation-net_http (0.22.7) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-pg (0.27.3) + opentelemetry-instrumentation-pg (0.27.4) opentelemetry-api (~> 1.0) opentelemetry-helpers-sql-obfuscation opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rack (0.24.5) + opentelemetry-instrumentation-rack (0.24.6) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rails (0.31.0) + opentelemetry-instrumentation-rails (0.31.1) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-action_mailer (~> 0.1.0) opentelemetry-instrumentation-action_pack (~> 0.9.0) @@ -565,20 +566,20 @@ GEM opentelemetry-instrumentation-active_record (~> 0.7.0) opentelemetry-instrumentation-active_support (~> 0.6.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-redis (0.25.6) + opentelemetry-instrumentation-redis (0.25.7) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-sidekiq (0.25.6) + opentelemetry-instrumentation-sidekiq (0.25.7) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-registry (0.3.1) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.4.1) + opentelemetry-sdk (1.5.0) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions - opentelemetry-semantic_conventions (1.10.0) + opentelemetry-semantic_conventions (1.10.1) opentelemetry-api (~> 1.0) orm_adapter (0.5.0) ox (2.14.18) @@ -589,7 +590,7 @@ GEM parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.5.6) + pg (1.5.7) pghero (3.6.0) activerecord (>= 6.1) premailer (1.23.0) @@ -607,13 +608,13 @@ GEM railties (>= 7.0.0) psych (5.1.2) stringio - public_suffix (6.0.0) + public_suffix (6.0.1) puma (6.4.2) nio4r (~> 2.0) pundit (2.3.2) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.8.0) + racc (1.8.1) rack (2.2.9) rack-attack (6.7.0) rack (>= 1.0, < 4) @@ -675,8 +676,9 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - rdf (3.3.1) + rdf (3.3.2) bcp47_spec (~> 0.2) + bigdecimal (~> 3.1, >= 3.1.5) link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.7.0) rdf (~> 3.3) @@ -696,7 +698,7 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.3.2) + rexml (3.3.4) strscan rotp (6.3.0) rouge (4.2.1) @@ -733,7 +735,7 @@ GEM rspec-mocks (~> 3.0) sidekiq (>= 5, < 8) rspec-support (3.13.1) - rubocop (1.65.0) + rubocop (1.65.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -756,7 +758,7 @@ GEM rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (3.0.3) + rubocop-rspec (3.0.4) rubocop (~> 1.61) rubocop-rspec_rails (2.30.0) rubocop (~> 1.61) @@ -775,13 +777,13 @@ GEM fugit (~> 1.1, >= 1.1.6) safety_net_attestation (0.4.0) jwt (~> 2.0) - sanitize (6.1.1) + sanitize (6.1.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) scenic (1.8.0) activerecord (>= 4.0.0) railties (>= 4.0.0) - selenium-webdriver (4.22.0) + selenium-webdriver (4.23.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -794,7 +796,7 @@ GEM redis (>= 4.5.0, < 5) sidekiq-bulk (0.2.0) sidekiq - sidekiq-scheduler (5.0.5) + sidekiq-scheduler (5.0.6) rufus-scheduler (~> 3.2) sidekiq (>= 6, < 8) tilt (>= 1.4.0, < 3) @@ -821,8 +823,8 @@ GEM stoplight (4.1.0) redlock (~> 1.0) stringio (3.1.1) - strong_migrations (1.8.0) - activerecord (>= 5.2) + strong_migrations (2.0.0) + activerecord (>= 6.1) strscan (3.1.0) swd (1.3.0) activesupport (>= 3) @@ -894,7 +896,7 @@ GEM railties (>= 5.2) semantic_range (>= 2.3.0) webrick (1.8.1) - websocket (1.2.10) + websocket (1.2.11) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -943,7 +945,7 @@ DEPENDENCIES fast_blank (~> 1.0) fastimage flatware-rspec - fog-core (<= 2.4.0) + fog-core (<= 2.5.0) fog-openstack (~> 1.0) fuubar (~> 2.5) haml-rails (~> 2.0) @@ -983,7 +985,7 @@ DEPENDENCIES omniauth-rails_csrf_protection (~> 1.0) omniauth-saml (~> 2.0) omniauth_openid_connect (~> 0.6.1) - opentelemetry-api (~> 1.2.5) + opentelemetry-api (~> 1.3.0) opentelemetry-exporter-otlp (~> 0.28.0) opentelemetry-instrumentation-active_job (~> 0.7.1) opentelemetry-instrumentation-active_model_serializers (~> 0.20.1) @@ -1046,7 +1048,7 @@ DEPENDENCIES simplecov-lcov (~> 0.8) stackprof stoplight (~> 4.1) - strong_migrations (= 1.8.0) + strong_migrations test-prof thor (~> 1.2) tty-prompt (~> 0.23) diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index a6997b62f7db6b..d7f88a71f3698f 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -13,6 +13,7 @@ def index def show authorize :instance, :show? @time_period = (6.days.ago.to_date...Time.now.utc.to_date) + @action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(5) end def destroy diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index 4f727c398a0be0..4759d15bc4b047 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -2,7 +2,15 @@ module Admin class TagsController < BaseController - before_action :set_tag + before_action :set_tag, except: [:index] + + PER_PAGE = 20 + + def index + authorize :tag, :index? + + @tags = filtered_tags.page(params[:page]).per(PER_PAGE) + end def show authorize @tag, :show? @@ -31,5 +39,13 @@ def set_tag def tag_params params.require(:tag).permit(:name, :display_name, :trendable, :usable, :listable) end + + def filtered_tags + TagFilter.new(filter_params.with_defaults(order: 'newest')).results + end + + def filter_params + params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS) + end end end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index c1a5e43f882dd0..0980e0ebbc60be 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -30,10 +30,10 @@ def doorkeeper_forbidden_render_options(*) protected - def limit_param(default_limit) + def limit_param(default_limit, max_limit = nil) return default_limit unless params[:limit] - [params[:limit].to_i.abs, default_limit * 2].min + [params[:limit].to_i.abs, max_limit || (default_limit * 2)].min end def params_slice(*keys) diff --git a/app/controllers/api/v1/admin/domain_allows_controller.rb b/app/controllers/api/v1/admin/domain_allows_controller.rb index 9801d832b8ba84..24f68aa1bd8746 100644 --- a/app/controllers/api/v1/admin/domain_allows_controller.rb +++ b/app/controllers/api/v1/admin/domain_allows_controller.rb @@ -5,6 +5,7 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController include AccountableConcern LIMIT = 100 + MAX_LIMIT = 500 before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_allows' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_allows' }, except: [:index, :show] @@ -47,18 +48,13 @@ def destroy private def set_domain_allows - @domain_allows = filtered_domain_allows.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + @domain_allows = DomainAllow.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT, MAX_LIMIT), params_slice(:max_id, :since_id, :min_id)) end def set_domain_allow @domain_allow = DomainAllow.find(params[:id]) end - def filtered_domain_allows - # TODO: no filtering yet - DomainAllow.all - end - def next_path api_v1_admin_domain_allows_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -72,7 +68,7 @@ def pagination_collection end def records_continue? - @domain_allows.size == limit_param(LIMIT) + @domain_allows.size == limit_param(LIMIT, MAX_LIMIT) end def resource_params diff --git a/app/controllers/api/v1/admin/domain_blocks_controller.rb b/app/controllers/api/v1/admin/domain_blocks_controller.rb index a20a4a9c7f8fbe..b44ae2ae2a2130 100644 --- a/app/controllers/api/v1/admin/domain_blocks_controller.rb +++ b/app/controllers/api/v1/admin/domain_blocks_controller.rb @@ -5,6 +5,7 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController include AccountableConcern LIMIT = 100 + MAX_LIMIT = 500 before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_blocks' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_blocks' }, except: [:index, :show] @@ -59,18 +60,13 @@ def conflicts_with_existing_block?(domain_block, existing_domain_block) end def set_domain_blocks - @domain_blocks = filtered_domain_blocks.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + @domain_blocks = DomainBlock.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT, MAX_LIMIT), params_slice(:max_id, :since_id, :min_id)) end def set_domain_block @domain_block = DomainBlock.find(params[:id]) end - def filtered_domain_blocks - # TODO: no filtering yet - DomainBlock.all - end - def domain_block_params params.permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate) end @@ -88,7 +84,7 @@ def pagination_collection end def records_continue? - @domain_blocks.size == limit_param(LIMIT) + @domain_blocks.size == limit_param(LIMIT, MAX_LIMIT) end def resource_params diff --git a/app/controllers/api/v1/notifications/policies_controller.rb b/app/controllers/api/v1/notifications/policies_controller.rb index 1ec336f9a594dc..9d70c283bec001 100644 --- a/app/controllers/api/v1/notifications/policies_controller.rb +++ b/app/controllers/api/v1/notifications/policies_controller.rb @@ -8,12 +8,12 @@ class Api::V1::Notifications::PoliciesController < Api::BaseController before_action :set_policy def show - render json: @policy, serializer: REST::NotificationPolicySerializer + render json: @policy, serializer: REST::V1::NotificationPolicySerializer end def update @policy.update!(resource_params) - render json: @policy, serializer: REST::NotificationPolicySerializer + render json: @policy, serializer: REST::V1::NotificationPolicySerializer end private diff --git a/app/controllers/api/v1/notifications/requests_controller.rb b/app/controllers/api/v1/notifications/requests_controller.rb index 9ae80c28ed0732..0710166d05b89c 100644 --- a/app/controllers/api/v1/notifications/requests_controller.rb +++ b/app/controllers/api/v1/notifications/requests_controller.rb @@ -5,7 +5,8 @@ class Api::V1::Notifications::RequestsController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: :index before_action :require_user! - before_action :set_request, except: :index + before_action :set_request, only: [:show, :accept, :dismiss] + before_action :set_requests, only: [:accept_bulk, :dismiss_bulk] after_action :insert_pagination_headers, only: :index @@ -28,7 +29,17 @@ def accept end def dismiss - @request.destroy! + DismissNotificationRequestService.new.call(@request) + render_empty + end + + def accept_bulk + @requests.each { |request| AcceptNotificationRequestService.new.call(request) } + render_empty + end + + def dismiss_bulk + @requests.each(&:destroy!) render_empty end @@ -53,6 +64,10 @@ def set_request @request = NotificationRequest.where(account: current_account).find(params[:id]) end + def set_requests + @requests = NotificationRequest.where(account: current_account, id: Array(params[:id]).uniq.map(&:to_i)) + end + def next_path api_v1_notifications_requests_url pagination_params(max_id: pagination_max_id) unless @requests.empty? end diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index c82900ef661812..bdc163d4b67036 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -7,6 +7,8 @@ class Api::V1::NotificationsController < Api::BaseController after_action :insert_pagination_headers, only: :index DEFAULT_NOTIFICATIONS_LIMIT = 40 + DEFAULT_NOTIFICATIONS_COUNT_LIMIT = 100 + MAX_NOTIFICATIONS_COUNT_LIMIT = 1_000 def index with_read_replica do @@ -17,6 +19,14 @@ def index render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships end + def unread_count + limit = limit_param(DEFAULT_NOTIFICATIONS_COUNT_LIMIT, MAX_NOTIFICATIONS_COUNT_LIMIT) + + with_read_replica do + render json: { count: browserable_account_notifications.paginate_by_min_id(limit, notification_marker&.last_read_id).count } + end + end + def show @notification = current_account.notifications.without_suspended.find(params[:id]) render json: @notification, serializer: REST::NotificationSerializer @@ -63,6 +73,10 @@ def browserable_account_notifications ) end + def notification_marker + current_user.markers.find_by(timeline: 'notifications') + end + def target_statuses_from_notifications @notifications.reject { |notification| notification.target_status.nil? }.map(&:target_status) end diff --git a/app/controllers/api/v2/notifications/policies_controller.rb b/app/controllers/api/v2/notifications/policies_controller.rb new file mode 100644 index 00000000000000..637587967fe328 --- /dev/null +++ b/app/controllers/api/v2/notifications/policies_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Api::V2::Notifications::PoliciesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :show + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: :update + + before_action :require_user! + before_action :set_policy + + def show + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + def update + @policy.update!(resource_params) + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + private + + def set_policy + @policy = NotificationPolicy.find_or_initialize_by(account: current_account) + + with_read_replica do + @policy.summarize! + end + end + + def resource_params + params.permit( + :for_not_following, + :for_not_followers, + :for_new_accounts, + :for_private_mentions, + :for_limited_accounts + ) + end +end diff --git a/app/controllers/api/v2_alpha/notifications_controller.rb b/app/controllers/api/v2_alpha/notifications_controller.rb index 83d40a0886f52f..d0205ad6af890e 100644 --- a/app/controllers/api/v2_alpha/notifications_controller.rb +++ b/app/controllers/api/v2_alpha/notifications_controller.rb @@ -7,6 +7,8 @@ class Api::V2Alpha::NotificationsController < Api::BaseController after_action :insert_pagination_headers, only: :index DEFAULT_NOTIFICATIONS_LIMIT = 40 + DEFAULT_NOTIFICATIONS_COUNT_LIMIT = 100 + MAX_NOTIFICATIONS_COUNT_LIMIT = 1_000 def index with_read_replica do @@ -14,10 +16,10 @@ def index @group_metadata = load_group_metadata @grouped_notifications = load_grouped_notifications @relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id) - @sample_accounts = @grouped_notifications.flat_map(&:sample_accounts) + @presenter = GroupedNotificationsPresenter.new(@grouped_notifications, expand_accounts: expand_accounts_param) # Preload associations to avoid N+1s - ActiveRecord::Associations::Preloader.new(records: @sample_accounts, associations: [:account_stat, { user: :role }]).call + ActiveRecord::Associations::Preloader.new(records: @presenter.accounts, associations: [:account_stat, { user: :role }]).call end MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#index rendering') do |span| @@ -25,19 +27,29 @@ def index span.add_attributes( 'app.notification_grouping.count' => @grouped_notifications.size, - 'app.notification_grouping.sample_account.count' => @sample_accounts.size, - 'app.notification_grouping.sample_account.unique_count' => @sample_accounts.pluck(:id).uniq.size, + 'app.notification_grouping.account.count' => @presenter.accounts.size, + 'app.notification_grouping.partial_account.count' => @presenter.partial_accounts.size, 'app.notification_grouping.status.count' => statuses.size, - 'app.notification_grouping.status.unique_count' => statuses.uniq.size + 'app.notification_grouping.status.unique_count' => statuses.uniq.size, + 'app.notification_grouping.expand_accounts_param' => expand_accounts_param ) - render json: @grouped_notifications, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata + render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata, expand_accounts: expand_accounts_param + end + end + + def unread_count + limit = limit_param(DEFAULT_NOTIFICATIONS_COUNT_LIMIT, MAX_NOTIFICATIONS_COUNT_LIMIT) + + with_read_replica do + render json: { count: browserable_account_notifications.paginate_groups_by_min_id(limit, min_id: notification_marker&.last_read_id).count } end end def show @notification = current_account.notifications.without_suspended.find_by!(group_key: params[:id]) - render json: NotificationGroup.from_notification(@notification), serializer: REST::NotificationGroupSerializer + presenter = GroupedNotificationsPresenter.new([NotificationGroup.from_notification(@notification)]) + render json: presenter, serializer: REST::DedupNotificationGroupSerializer end def clear @@ -92,6 +104,10 @@ def browserable_account_notifications ) end + def notification_marker + current_user.markers.find_by(timeline: 'notifications') + end + def target_statuses_from_notifications @notifications.filter_map(&:target_status) end @@ -115,4 +131,15 @@ def browserable_params def pagination_params(core_params) params.slice(:limit, :types, :exclude_types, :include_filtered).permit(:limit, :include_filtered, types: [], exclude_types: []).merge(core_params) end + + def expand_accounts_param + case params[:expand_accounts] + when nil, 'full' + 'full' + when 'partial_avatars' + 'partial_avatars' + else + raise Mastodon::InvalidParameterError, "Invalid value for 'expand_accounts': '#{params[:expand_accounts]}', allowed values are 'full' and 'partial_avatars'" + end + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1d700fa282e458..9ec50926671eaf 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -23,7 +23,6 @@ class ApplicationController < ActionController::Base helper_method :current_theme helper_method :single_user_mode? helper_method :use_seamless_external_login? - helper_method :omniauth_only? helper_method :sso_account_settings helper_method :limited_federation_mode? helper_method :body_class_string @@ -140,10 +139,6 @@ def use_seamless_external_login? Devise.pam_authentication || Devise.ldap_authentication end - def omniauth_only? - ENV['OMNIAUTH_ONLY'] == 'true' - end - def sso_account_settings ENV.fetch('SSO_ACCOUNT_SETTINGS', nil) end diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb index 7ca7be5f8ef8ec..bf5c84ccf45a88 100644 --- a/app/controllers/auth/confirmations_controller.rb +++ b/app/controllers/auth/confirmations_controller.rb @@ -5,7 +5,6 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController layout 'auth' - before_action :set_body_classes before_action :set_confirmation_user!, only: [:show, :confirm_captcha] before_action :redirect_confirmed_user, if: :signed_in_confirmed_user? @@ -73,10 +72,6 @@ def signed_in_confirmed_user? user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank? end - def set_body_classes - @body_classes = 'lighter' - end - def after_resending_confirmation_instructions_path_for(_resource_name) if user_signed_in? if current_user.confirmed? && current_user.approved? diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index de001f062b04d7..7c1ff59671d94c 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -3,7 +3,6 @@ class Auth::PasswordsController < Devise::PasswordsController skip_before_action :check_self_destruct! before_action :redirect_invalid_reset_token, only: :edit, unless: :reset_password_token_is_valid? - before_action :set_body_classes layout 'auth' @@ -24,10 +23,6 @@ def redirect_invalid_reset_token redirect_to new_password_path(resource_name) end - def set_body_classes - @body_classes = 'lighter' - end - def reset_password_token_is_valid? resource_class.with_reset_password_token(params[:reset_password_token]).present? end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index e5a2ac0270f012..c12960934e35e5 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -105,7 +105,7 @@ def invite_code private def set_body_classes - @body_classes = %w(edit update).include?(action_name) ? 'admin' : 'lighter' + @body_classes = 'admin' if %w(edit update).include?(action_name) end def set_invite diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 6ed7b2baacc7c4..6210e4dbf9a721 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -16,8 +16,6 @@ class Auth::SessionsController < Devise::SessionsController include Auth::TwoFactorAuthenticationConcern - before_action :set_body_classes - content_security_policy only: :new do |p| p.form_action(false) end @@ -103,10 +101,6 @@ def require_no_authentication private - def set_body_classes - @body_classes = 'lighter' - end - def home_paths(resource) paths = [about_path, '/explore'] diff --git a/app/controllers/auth/setup_controller.rb b/app/controllers/auth/setup_controller.rb index 40916d2887702c..ad872dc607285f 100644 --- a/app/controllers/auth/setup_controller.rb +++ b/app/controllers/auth/setup_controller.rb @@ -5,7 +5,6 @@ class Auth::SetupController < ApplicationController before_action :authenticate_user! before_action :require_unconfirmed_or_pending! - before_action :set_body_classes before_action :set_user skip_before_action :require_functional! @@ -35,10 +34,6 @@ def set_user @user = current_user end - def set_body_classes - @body_classes = 'lighter' - end - def user_params params.require(:user).permit(:email) end diff --git a/app/controllers/concerns/auth/two_factor_authentication_concern.rb b/app/controllers/concerns/auth/two_factor_authentication_concern.rb index 404164751a86cb..0fb11428dca520 100644 --- a/app/controllers/concerns/auth/two_factor_authentication_concern.rb +++ b/app/controllers/concerns/auth/two_factor_authentication_concern.rb @@ -83,7 +83,6 @@ def authenticate_with_two_factor_via_otp(user) def prompt_for_two_factor(user) register_attempt_in_session(user) - @body_classes = 'lighter' @webauthn_enabled = user.webauthn_enabled? @scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank? 'webauthn' diff --git a/app/controllers/concerns/challengable_concern.rb b/app/controllers/concerns/challengable_concern.rb index 09874fb4054435..c8d1a0bef7f013 100644 --- a/app/controllers/concerns/challengable_concern.rb +++ b/app/controllers/concerns/challengable_concern.rb @@ -42,7 +42,6 @@ def require_challenge! end def render_challenge - @body_classes = 'lighter' render 'auth/challenges/new', layout: 'auth' end diff --git a/app/controllers/mail_subscriptions_controller.rb b/app/controllers/mail_subscriptions_controller.rb index 1caeaaacf4ce44..34df75f63ad6c4 100644 --- a/app/controllers/mail_subscriptions_controller.rb +++ b/app/controllers/mail_subscriptions_controller.rb @@ -5,7 +5,6 @@ class MailSubscriptionsController < ApplicationController skip_before_action :require_functional! - before_action :set_body_classes before_action :set_user before_action :set_type @@ -25,10 +24,6 @@ def set_user not_found unless @user end - def set_body_classes - @body_classes = 'lighter' - end - def set_type @type = email_type_from_param end diff --git a/app/helpers/admin/tags_helper.rb b/app/helpers/admin/tags_helper.rb new file mode 100644 index 00000000000000..eb928a6db2beb0 --- /dev/null +++ b/app/helpers/admin/tags_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Admin::TagsHelper + def admin_tags_moderation_options + [ + [t('admin.tags.moderation.reviewed'), 'reviewed'], + [t('admin.tags.moderation.review_requested'), 'review_requested'], + [t('admin.tags.moderation.unreviewed'), 'unreviewed'], + [t('admin.tags.moderation.trendable'), 'trendable'], + [t('admin.tags.moderation.not_trendable'), 'not_trendable'], + [t('admin.tags.moderation.usable'), 'usable'], + [t('admin.tags.moderation.not_usable'), 'not_usable'], + ] + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2369cff7e65140..ed89d815634945 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -116,7 +116,7 @@ def fa_icon(icon, attributes = {}) def material_symbol(icon, attributes = {}) inline_svg_tag( "400-24px/#{icon}.svg", - class: %w(icon).concat(attributes[:class].to_s.split), + class: ['icon', "material-#{icon}"].concat(attributes[:class].to_s.split), role: :img ) end @@ -127,23 +127,23 @@ def check_icon def visibility_icon(status) if status.public_visibility? - fa_icon('globe', title: I18n.t('statuses.visibilities.public')) + material_symbol('globe', title: I18n.t('statuses.visibilities.public')) elsif status.unlisted_visibility? - fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted')) + material_symbol('lock_open', title: I18n.t('statuses.visibilities.unlisted')) elsif status.private_visibility? || status.limited_visibility? - fa_icon('lock', title: I18n.t('statuses.visibilities.private')) + material_symbol('lock', title: I18n.t('statuses.visibilities.private')) elsif status.direct_visibility? - fa_icon('at', title: I18n.t('statuses.visibilities.direct')) + material_symbol('alternate_email', title: I18n.t('statuses.visibilities.direct')) end end def interrelationships_icon(relationships, account_id) if relationships.following[account_id] && relationships.followed_by[account_id] - fa_icon('exchange', title: I18n.t('relationships.mutual'), class: 'fa-fw active passive') + material_symbol('sync_alt', title: I18n.t('relationships.mutual'), class: 'active passive') elsif relationships.following[account_id] - fa_icon(locale_direction == 'ltr' ? 'arrow-right' : 'arrow-left', title: I18n.t('relationships.following'), class: 'fa-fw active') + material_symbol(locale_direction == 'ltr' ? 'arrow_right_alt' : 'arrow_left_alt', title: I18n.t('relationships.following'), class: 'active') elsif relationships.followed_by[account_id] - fa_icon(locale_direction == 'ltr' ? 'arrow-left' : 'arrow-right', title: I18n.t('relationships.followers'), class: 'fa-fw passive') + material_symbol(locale_direction == 'ltr' ? 'arrow_left_alt' : 'arrow_right_alt', title: I18n.t('relationships.followers'), class: 'passive') end end diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index ca693a8a78a9af..d956e4fcd8fb29 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -60,13 +60,13 @@ def stream_link_target def fa_visibility_icon(status) case status.visibility when 'public' - fa_icon 'globe fw' + material_symbol 'globe' when 'unlisted' - fa_icon 'unlock fw' + material_symbol 'lock_open' when 'private' - fa_icon 'lock fw' + material_symbol 'lock' when 'direct' - fa_icon 'at fw' + material_symbol 'alternate_email' end end diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 40a9b7c0ca6470..b06675c2ee2cce 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -316,8 +316,8 @@ function loaded() { const message = statusEl.dataset.spoiler === 'expanded' - ? localeData['status.show_less'] ?? 'Show less' - : localeData['status.show_more'] ?? 'Show more'; + ? (localeData['status.show_less'] ?? 'Show less') + : (localeData['status.show_more'] ?? 'Show more'); spoilerLink.textContent = new IntlMessageFormat( message, locale, @@ -431,6 +431,42 @@ Rails.delegate(document, 'img.custom-emoji', 'mouseout', ({ target }) => { target.src = target.dataset.static; }); +const setInputDisabled = ( + input: HTMLInputElement | HTMLSelectElement, + disabled: boolean, +) => { + input.disabled = disabled; + + const wrapper = input.closest('.with_label'); + if (wrapper) { + wrapper.classList.toggle('disabled', input.disabled); + + const hidden = + input.type === 'checkbox' && + wrapper.querySelector('input[type=hidden][value="0"]'); + if (hidden) { + hidden.disabled = input.disabled; + } + } +}; + +Rails.delegate( + document, + '#account_statuses_cleanup_policy_enabled', + 'change', + ({ target }) => { + if (!(target instanceof HTMLInputElement) || !target.form) return; + + target.form + .querySelectorAll< + HTMLInputElement | HTMLSelectElement + >('input:not([type=hidden], #account_statuses_cleanup_policy_enabled), select') + .forEach((input) => { + setInputDisabled(input, !target.checked); + }); + }, +); + // Empty the honeypot fields in JS in case something like an extension // automatically filled them. Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js index 7c31c16998d0a3..699b92dd09f4bb 100644 --- a/app/javascript/flavours/glitch/actions/accounts.js +++ b/app/javascript/flavours/glitch/actions/accounts.js @@ -1,3 +1,5 @@ +import { browserHistory } from 'flavours/glitch/components/router'; + import api, { getLinks } from '../api'; import { @@ -722,6 +724,16 @@ export const updateAccount = ({ displayName, note, avatar, header, discoverable, }); }; +export const navigateToProfile = (accountId) => { + return (_dispatch, getState) => { + const acct = getState().accounts.getIn([accountId, 'acct']); + + if (acct) { + browserHistory.push(`/@${acct}`); + } + }; +}; + export function fetchPinnedAccountsSuggestions(q) { return (dispatch) => { dispatch(fetchPinnedAccountsSuggestionsRequest()); diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 3e15d6ff0951b6..70bc5bce42513d 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -4,6 +4,7 @@ import axios from 'axios'; import { throttle } from 'lodash'; import api from 'flavours/glitch/api'; +import { browserHistory } from 'flavours/glitch/components/router'; import { search as emojiSearch } from 'flavours/glitch/features/emoji/emoji_mart_search_light'; import { tagHistory } from 'flavours/glitch/settings'; import { recoverHashtags } from 'flavours/glitch/utils/hashtag'; @@ -97,9 +98,9 @@ const messages = defineMessages({ saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' }, }); -export const ensureComposeIsVisible = (getState, routerHistory) => { +export const ensureComposeIsVisible = (getState) => { if (!getState().getIn(['compose', 'mounted'])) { - routerHistory.push('/publish'); + browserHistory.push('/publish'); } }; @@ -120,7 +121,7 @@ export function changeCompose(text) { }; } -export function replyCompose(status, routerHistory) { +export function replyCompose(status) { return (dispatch, getState) => { const prependCWRe = getState().getIn(['local_settings', 'prepend_cw_re']); dispatch({ @@ -129,7 +130,19 @@ export function replyCompose(status, routerHistory) { prependCWRe: prependCWRe, }); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); + }; +} + +export function replyComposeById(statusId) { + return (dispatch, getState) => { + const state = getState(); + const status = state.statuses.get(statusId); + + if (status) { + const account = state.accounts.get(status.get('account')); + dispatch(replyCompose(status.set('account', account))); + } }; } @@ -145,38 +158,44 @@ export function resetCompose() { }; } -export const focusCompose = (routerHistory, defaultText) => (dispatch, getState) => { +export const focusCompose = (defaultText) => (dispatch, getState) => { dispatch({ type: COMPOSE_FOCUS, defaultText, }); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); }; -export function mentionCompose(account, routerHistory) { +export function mentionCompose(account) { return (dispatch, getState) => { dispatch({ type: COMPOSE_MENTION, account: account, }); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); + }; +} + +export function mentionComposeById(accountId) { + return (dispatch, getState) => { + dispatch(mentionCompose(getState().accounts.get(accountId))); }; } -export function directCompose(account, routerHistory) { +export function directCompose(account) { return (dispatch, getState) => { dispatch({ type: COMPOSE_DIRECT, account: account, }); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); }; } -export function submitCompose(routerHistory, overridePrivacy = null) { +export function submitCompose(overridePrivacy = null) { return function (dispatch, getState) { let status = getState().getIn(['compose', 'text'], ''); const media = getState().getIn(['compose', 'media_attachments']); @@ -233,11 +252,10 @@ export function submitCompose(routerHistory, overridePrivacy = null) { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), }, }).then(function (response) { - if (routerHistory - && (routerHistory.location.pathname === '/publish' || routerHistory.location.pathname === '/statuses/new') + if ((browserHistory.location.pathname === '/publish' || browserHistory.location.pathname === '/statuses/new') && window.history.state && !getState().getIn(['compose', 'advanced_options', 'threaded_mode'])) { - routerHistory.goBack(); + browserHistory.goBack(); } dispatch(insertIntoTagHistory(response.data.tags, status)); @@ -275,7 +293,7 @@ export function submitCompose(routerHistory, overridePrivacy = null) { message: statusId === null ? messages.published : messages.saved, action: messages.open, dismissAfter: 10000, - onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`), + onClick: () => browserHistory.push(`/@${response.data.account.username}/${response.data.id}`), })); } }).catch(function (error) { diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js index 57f2459c016fa4..43673642f5cb89 100644 --- a/app/javascript/flavours/glitch/actions/interactions.js +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -1,7 +1,11 @@ +import { boostModal, favouriteModal } from 'flavours/glitch/initial_state'; + import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; +import { unreblog, reblog } from './interactions_typed'; +import { openModal } from './modal'; export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; @@ -432,3 +436,61 @@ export function unpinFail(status, error) { skipLoading: true, }; } + +function toggleReblogWithoutConfirmation(status, privacy) { + return (dispatch) => { + if (status.get('reblogged')) { + dispatch(unreblog({ statusId: status.get('id') })); + } else { + dispatch(reblog({ statusId: status.get('id'), privacy })); + } + }; +} + +export function toggleReblog(statusId, skipModal = false) { + return (dispatch, getState) => { + const state = getState(); + let status = state.statuses.get(statusId); + + if (!status) + return; + + // The reblog modal expects a pre-filled account in status + // TODO: fix this by having the reblog modal get a statusId and do the work itself + status = status.set('account', state.accounts.get(status.get('account'))); + + const missing_description_setting = state.getIn(['local_settings', 'confirm_boost_missing_media_description']); + const missing_description = status.get('media_attachments').some(item => !item.get('description')); + if (missing_description_setting && missing_description && !status.get('reblogged')) { + dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: (status, privacy) => dispatch(toggleReblogWithoutConfirmation(status, privacy)), missingMediaDescription: true } })); + } else if (boostModal && !skipModal) { + dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: (status, privacy) => dispatch(toggleReblogWithoutConfirmation(status, privacy)) } })); + } else { + dispatch(toggleReblogWithoutConfirmation(status)); + } + }; +} + +export function toggleFavourite(statusId, skipModal = false) { + return (dispatch, getState) => { + const state = getState(); + let status = state.statuses.get(statusId); + + if (!status) + return; + + // The favourite modal expects a pre-filled account in status + // TODO: fix this by having the reblog modal get a statusId and do the work itself + status = status.set('account', state.accounts.get(status.get('account'))); + + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + if (favouriteModal && !skipModal) { + dispatch(openModal({ modalType: 'FAVOURITE', modalProps: { status, onFavourite: (status) => dispatch(favourite(status)) } })); + } else { + dispatch(favourite(status)); + } + } + }; +} diff --git a/app/javascript/flavours/glitch/actions/notification_groups.ts b/app/javascript/flavours/glitch/actions/notification_groups.ts index 3f6d14a978b154..e034792275271f 100644 --- a/app/javascript/flavours/glitch/actions/notification_groups.ts +++ b/app/javascript/flavours/glitch/actions/notification_groups.ts @@ -38,10 +38,6 @@ function dispatchAssociatedRecords( const fetchedStatuses: ApiStatusJSON[] = []; notifications.forEach((notification) => { - if ('sample_accounts' in notification) { - fetchedAccounts.push(...notification.sample_accounts); - } - if (notification.type === 'admin.report') { fetchedAccounts.push(notification.report.target_account); } @@ -75,7 +71,9 @@ export const fetchNotifications = createDataLoadingThunk( : excludeAllTypesExcept(activeFilter), }); }, - ({ notifications }, { dispatch }) => { + ({ notifications, accounts, statuses }, { dispatch }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch(importFetchedStatuses(statuses)); dispatchAssociatedRecords(dispatch, notifications); const payload: (ApiNotificationGroupJSON | NotificationGap)[] = notifications; @@ -95,7 +93,9 @@ export const fetchNotificationsGap = createDataLoadingThunk( async (params: { gap: NotificationGap }) => apiFetchNotifications({ max_id: params.gap.maxId }), - ({ notifications }, { dispatch }) => { + ({ notifications, accounts, statuses }, { dispatch }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch(importFetchedStatuses(statuses)); dispatchAssociatedRecords(dispatch, notifications); return { notifications }; diff --git a/app/javascript/flavours/glitch/actions/notification_policies.ts b/app/javascript/flavours/glitch/actions/notification_policies.ts index 76452de324b426..65b9882d3baf14 100644 --- a/app/javascript/flavours/glitch/actions/notification_policies.ts +++ b/app/javascript/flavours/glitch/actions/notification_policies.ts @@ -1,3 +1,5 @@ +import { createAction } from '@reduxjs/toolkit'; + import { apiGetNotificationPolicy, apiUpdateNotificationsPolicy, @@ -14,3 +16,7 @@ export const updateNotificationsPolicy = createDataLoadingThunk( 'notificationPolicy/update', (policy: Partial) => apiUpdateNotificationsPolicy(policy), ); + +export const decreasePendingNotificationsCount = createAction( + 'notificationPolicy/decreasePendingNotificationCount', +); diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 7a97e71bdd22e5..86e2d6eb4eb20e 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -18,6 +18,7 @@ import { importFetchedStatuses, } from './importer'; import { submitMarkers } from './markers'; +import { decreasePendingNotificationsCount } from './notification_policies'; import { notificationsUpdate } from "./notifications_typed"; import { register as registerPushNotifications } from './push_notifications'; import { saveSettings } from './settings'; @@ -76,6 +77,14 @@ export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMIS export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS'; export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL'; +export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST'; +export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS'; +export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL'; + +export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISMISS_REQUEST'; +export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS'; +export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL'; + export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST'; export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS'; export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL'; @@ -96,6 +105,12 @@ const fetchRelatedRelationships = (dispatch, notifications) => { } }; +const selectNotificationCountForRequest = (state, id) => { + const requests = state.getIn(['notificationRequests', 'items']); + const thisRequest = requests.find(request => request.get('id') === id); + return thisRequest ? thisRequest.get('notifications_count') : 0; +}; + export const loadPending = () => ({ type: NOTIFICATIONS_LOAD_PENDING, }); @@ -521,11 +536,13 @@ export const fetchNotificationRequestFail = (id, error) => ({ error, }); -export const acceptNotificationRequest = id => (dispatch) => { +export const acceptNotificationRequest = (id) => (dispatch, getState) => { + const count = selectNotificationCountForRequest(getState(), id); dispatch(acceptNotificationRequestRequest(id)); api().post(`/api/v1/notifications/requests/${id}/accept`).then(() => { dispatch(acceptNotificationRequestSuccess(id)); + dispatch(decreasePendingNotificationsCount(count)); }).catch(err => { dispatch(acceptNotificationRequestFail(id, err)); }); @@ -547,11 +564,13 @@ export const acceptNotificationRequestFail = (id, error) => ({ error, }); -export const dismissNotificationRequest = id => (dispatch) => { +export const dismissNotificationRequest = (id) => (dispatch, getState) => { + const count = selectNotificationCountForRequest(getState(), id); dispatch(dismissNotificationRequestRequest(id)); api().post(`/api/v1/notifications/requests/${id}/dismiss`).then(() =>{ dispatch(dismissNotificationRequestSuccess(id)); + dispatch(decreasePendingNotificationsCount(count)); }).catch(err => { dispatch(dismissNotificationRequestFail(id, err)); }); @@ -573,6 +592,62 @@ export const dismissNotificationRequestFail = (id, error) => ({ error, }); +export const acceptNotificationRequests = (ids) => (dispatch, getState) => { + const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0); + dispatch(acceptNotificationRequestsRequest(ids)); + + api().post(`/api/v1/notifications/requests/accept`, { id: ids }).then(() => { + dispatch(acceptNotificationRequestsSuccess(ids)); + dispatch(decreasePendingNotificationsCount(count)); + }).catch(err => { + dispatch(acceptNotificationRequestFail(ids, err)); + }); +}; + +export const acceptNotificationRequestsRequest = ids => ({ + type: NOTIFICATION_REQUESTS_ACCEPT_REQUEST, + ids, +}); + +export const acceptNotificationRequestsSuccess = ids => ({ + type: NOTIFICATION_REQUESTS_ACCEPT_SUCCESS, + ids, +}); + +export const acceptNotificationRequestsFail = (ids, error) => ({ + type: NOTIFICATION_REQUESTS_ACCEPT_FAIL, + ids, + error, +}); + +export const dismissNotificationRequests = (ids) => (dispatch, getState) => { + const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0); + dispatch(acceptNotificationRequestsRequest(ids)); + + api().post(`/api/v1/notifications/requests/dismiss`, { id: ids }).then(() => { + dispatch(dismissNotificationRequestsSuccess(ids)); + dispatch(decreasePendingNotificationsCount(count)); + }).catch(err => { + dispatch(dismissNotificationRequestFail(ids, err)); + }); +}; + +export const dismissNotificationRequestsRequest = ids => ({ + type: NOTIFICATION_REQUESTS_DISMISS_REQUEST, + ids, +}); + +export const dismissNotificationRequestsSuccess = ids => ({ + type: NOTIFICATION_REQUESTS_DISMISS_SUCCESS, + ids, +}); + +export const dismissNotificationRequestsFail = (ids, error) => ({ + type: NOTIFICATION_REQUESTS_DISMISS_FAIL, + ids, + error, +}); + export const fetchNotificationsForRequest = accountId => (dispatch, getState) => { const current = getState().getIn(['notificationRequests', 'current']); const params = { account_id: accountId }; diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index c4d292567d4d0c..2d83ae9926895a 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -1,3 +1,5 @@ +import { browserHistory } from 'flavours/glitch/components/router'; + import api from '../api'; import { ensureComposeIsVisible, setComposeToStatus } from './compose'; @@ -94,7 +96,7 @@ export function redraft(status, raw_text, content_type) { }; } -export const editStatus = (id, routerHistory) => (dispatch, getState) => { +export const editStatus = (id) => (dispatch, getState) => { let status = getState().getIn(['statuses', id]); if (status.get('poll')) { @@ -105,7 +107,7 @@ export const editStatus = (id, routerHistory) => (dispatch, getState) => { api().get(`/api/v1/statuses/${id}/source`).then(response => { dispatch(fetchStatusSourceSuccess()); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.content_type)); }).catch(error => { dispatch(fetchStatusSourceFail(error)); @@ -125,7 +127,7 @@ export const fetchStatusSourceFail = error => ({ error, }); -export function deleteStatus(id, routerHistory, withRedraft = false) { +export function deleteStatus(id, withRedraft = false) { return (dispatch, getState) => { let status = getState().getIn(['statuses', id]); @@ -142,7 +144,7 @@ export function deleteStatus(id, routerHistory, withRedraft = false) { if (withRedraft) { dispatch(redraft(status, response.data.text, response.data.content_type)); - ensureComposeIsVisible(getState, routerHistory); + ensureComposeIsVisible(getState); } }).catch(error => { dispatch(deleteStatusFail(id, error)); @@ -309,6 +311,21 @@ export function revealStatus(ids) { }; } +export function toggleStatusSpoilers(statusId) { + return (dispatch, getState) => { + const status = getState().statuses.get(statusId); + + if (!status) + return; + + if (status.get('hidden')) { + dispatch(revealStatus(statusId)); + } else { + dispatch(hideStatus(statusId)); + } + }; +} + export function toggleStatusCollapse(id, isCollapsed) { return { type: STATUS_COLLAPSE, @@ -349,3 +366,15 @@ export const undoStatusTranslation = (id, pollId) => ({ id, pollId, }); + +export const navigateToStatus = (statusId) => { + return (_dispatch, getState) => { + const state = getState(); + const accountId = state.statuses.getIn([statusId, 'account']); + const acct = state.accounts.getIn([accountId, 'acct']); + + if (acct) { + browserHistory.push(`/@${acct}/${statusId}`); + } + }; +}; diff --git a/app/javascript/flavours/glitch/api/notification_policies.ts b/app/javascript/flavours/glitch/api/notification_policies.ts index e52ea64f4142d8..1db79a6e74b782 100644 --- a/app/javascript/flavours/glitch/api/notification_policies.ts +++ b/app/javascript/flavours/glitch/api/notification_policies.ts @@ -2,8 +2,8 @@ import { apiRequestGet, apiRequestPut } from 'flavours/glitch/api'; import type { NotificationPolicyJSON } from 'flavours/glitch/api_types/notification_policies'; export const apiGetNotificationPolicy = () => - apiRequestGet('/v1/notifications/policy'); + apiRequestGet('/v2/notifications/policy'); export const apiUpdateNotificationsPolicy = ( policy: Partial, -) => apiRequestPut('/v1/notifications/policy', policy); +) => apiRequestPut('/v2/notifications/policy', policy); diff --git a/app/javascript/flavours/glitch/api/notifications.ts b/app/javascript/flavours/glitch/api/notifications.ts index fe718788220d44..781f4b1ddedbab 100644 --- a/app/javascript/flavours/glitch/api/notifications.ts +++ b/app/javascript/flavours/glitch/api/notifications.ts @@ -1,17 +1,24 @@ import api, { apiRequest, getLinks } from 'flavours/glitch/api'; -import type { ApiNotificationGroupJSON } from 'flavours/glitch/api_types/notifications'; +import type { ApiNotificationGroupsResultJSON } from 'flavours/glitch/api_types/notifications'; export const apiFetchNotifications = async (params?: { exclude_types?: string[]; max_id?: string; }) => { - const response = await api().request({ + const response = await api().request({ method: 'GET', url: '/api/v2_alpha/notifications', params, }); - return { notifications: response.data, links: getLinks(response) }; + const { statuses, accounts, notification_groups } = response.data; + + return { + statuses, + accounts, + notifications: notification_groups, + links: getLinks(response), + }; }; export const apiClearNotifications = () => diff --git a/app/javascript/flavours/glitch/api_types/notification_policies.ts b/app/javascript/flavours/glitch/api_types/notification_policies.ts index 0f4a2d132e0e0e..1c3970782cb464 100644 --- a/app/javascript/flavours/glitch/api_types/notification_policies.ts +++ b/app/javascript/flavours/glitch/api_types/notification_policies.ts @@ -1,10 +1,13 @@ // See app/serializers/rest/notification_policy_serializer.rb +export type NotificationPolicyValue = 'accept' | 'filter' | 'drop'; + export interface NotificationPolicyJSON { - filter_not_following: boolean; - filter_not_followers: boolean; - filter_new_accounts: boolean; - filter_private_mentions: boolean; + for_not_following: NotificationPolicyValue; + for_not_followers: NotificationPolicyValue; + for_new_accounts: NotificationPolicyValue; + for_private_mentions: NotificationPolicyValue; + for_limited_accounts: NotificationPolicyValue; summary: { pending_requests_count: number; pending_notifications_count: number; diff --git a/app/javascript/flavours/glitch/api_types/notifications.ts b/app/javascript/flavours/glitch/api_types/notifications.ts index ea37556d8d0af0..09dc8efe3123ad 100644 --- a/app/javascript/flavours/glitch/api_types/notifications.ts +++ b/app/javascript/flavours/glitch/api_types/notifications.ts @@ -51,7 +51,7 @@ export interface BaseNotificationGroupJSON { group_key: string; notifications_count: number; type: NotificationType; - sample_accounts: ApiAccountJSON[]; + sample_account_ids: string[]; latest_page_notification_at: string; // FIXME: This will only be present if the notification group is returned in a paginated list, not requested directly most_recent_notification_id: string; page_min_id?: string; @@ -60,7 +60,7 @@ export interface BaseNotificationGroupJSON { interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON { type: NotificationWithStatusType; - status: ApiStatusJSON; + status_id: string; } interface NotificationWithStatusJSON extends BaseNotificationJSON { @@ -143,3 +143,9 @@ export type ApiNotificationGroupJSON = | AccountRelationshipSeveranceNotificationGroupJSON | NotificationGroupWithStatusJSON | ModerationWarningNotificationGroupJSON; + +export interface ApiNotificationGroupsResultJSON { + accounts: ApiAccountJSON[]; + statuses: ApiStatusJSON[]; + notification_groups: ApiNotificationGroupJSON[]; +} diff --git a/app/javascript/flavours/glitch/components/account.jsx b/app/javascript/flavours/glitch/components/account.jsx index e2ea8995242b3b..29ffc60c6bacd5 100644 --- a/app/javascript/flavours/glitch/components/account.jsx +++ b/app/javascript/flavours/glitch/components/account.jsx @@ -106,7 +106,7 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica ); } else if (defaultAction === 'mute') { - buttons =