diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml index 4d5ed0f25f..5da1ec3a24 100644 --- a/.devcontainer/compose.yaml +++ b/.devcontainer/compose.yaml @@ -21,12 +21,13 @@ services: ES_HOST: es ES_PORT: '9200' LIBRE_TRANSLATE_ENDPOINT: http://libretranslate:5000 + LOCAL_DOMAIN: ${LOCAL_DOMAIN:-localhost:3000} # Overrides default command so things don't shut down after the process ends. command: sleep infinity ports: - - '127.0.0.1:3000:3000' - - '127.0.0.1:3035:3035' - - '127.0.0.1:4000:4000' + - '3000:3000' + - '3035:3035' + - '4000:4000' networks: - external_network - internal_network diff --git a/.env.production.sample b/.env.production.sample index 12ab2b6dcb..4afaf8d756 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -79,6 +79,9 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= S3_ALIAS_HOST=files.example.com +# Optional list of hosts that are allowed to serve media for your instance +# EXTRA_MEDIA_HOSTS=https://data.example1.com,https://data.example2.com + # IP and session retention # ----------------------- # Make sure to modify the scheduling of ip_cleanup_scheduler in config/sidekiq.yml diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 120b7df202..f589f5f03d 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` -# using RuboCop version 1.73.2. +# using RuboCop version 1.75.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -49,7 +49,7 @@ Style/FetchEnvVar: - 'lib/tasks/repo.rake' # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns. +# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, Mode, AllowedMethods, AllowedPatterns. # SupportedStyles: annotated, template, unannotated # AllowedMethods: redirect Style/FormatStringToken: @@ -77,7 +77,6 @@ Style/MapToHash: # AllowedMethods: respond_to_missing? Style/OptionalBooleanParameter: Exclude: - - 'app/helpers/json_ld_helper.rb' - 'app/lib/admin/system_check/message.rb' - 'app/lib/request.rb' - 'app/lib/webfinger.rb' diff --git a/Gemfile b/Gemfile index fba7371b64..b64a1dbe91 100644 --- a/Gemfile +++ b/Gemfile @@ -14,6 +14,7 @@ gem 'haml-rails', '~>2.0' gem 'pg', '~> 1.5' gem 'pghero' +gem 'aws-sdk-core', '< 3.216.0', require: false # TODO: https://github.com/mastodon/mastodon/pull/34173#issuecomment-2733378873 gem 'aws-sdk-s3', '~> 1.123', require: false gem 'blurhash', '~> 0.1' gem 'fog-core', '<= 2.6.0' diff --git a/Gemfile.lock b/Gemfile.lock index 66a597e99e..1ad5429d4b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -91,11 +91,11 @@ GEM aes_key_wrap (1.1.0) android_key_attestation (0.3.0) annotaterb (4.14.0) - ast (2.4.2) + ast (2.4.3) attr_required (1.0.2) - aws-eventstream (1.3.0) - aws-partitions (1.1032.0) - aws-sdk-core (3.214.1) + aws-eventstream (1.3.2) + aws-partitions (1.1066.0) + aws-sdk-core (3.215.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -107,9 +107,9 @@ GEM aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.10.1) + aws-sigv4 (1.11.0) aws-eventstream (~> 1, >= 1.0.2) - azure-blob (0.5.4) + azure-blob (0.5.7) rexml base64 (0.2.0) bcp47_spec (0.2.1) @@ -168,9 +168,9 @@ GEM bigdecimal rexml crass (1.0.6) - css_parser (1.21.0) + css_parser (1.21.1) addressable - csv (3.3.2) + csv (3.3.3) database_cleaner-active_record (2.2.0) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) @@ -222,7 +222,8 @@ GEM erubi (1.13.1) et-orbi (1.2.11) tzinfo - excon (1.2.3) + excon (1.2.5) + logger fabrication (2.31.0) faker (3.5.1) i18n (>= 1.8.11, < 2) @@ -256,7 +257,7 @@ GEM fog-json (1.2.0) fog-core multi_json (~> 1.10) - fog-openstack (1.1.4) + fog-openstack (1.1.5) fog-core (~> 2.1) fog-json (>= 1.0) formatador (1.1.0) @@ -265,7 +266,9 @@ GEM raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - google-protobuf (3.25.6) + google-protobuf (4.30.1) + bigdecimal + rake (>= 13) googleapis-common-protos-types (1.18.0) google-protobuf (>= 3.18, < 5.a) haml (6.3.0) @@ -277,7 +280,7 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.61.0) + haml_lint (0.61.1) haml (>= 5.0) parallel (~> 1.10) rainbow @@ -302,7 +305,8 @@ GEM domain_name (~> 0.5) http-form_data (2.3.0) http_accept_language (2.1.1) - httpclient (2.8.3) + httpclient (2.9.0) + mutex_m httplog (1.7.0) rack (>= 2.0) rainbow (>= 2.0.0) @@ -378,7 +382,7 @@ GEM mime-types terrapin (>= 0.6.0, < 2.0) language_server-protocol (3.17.0.4) - launchy (3.1.0) + launchy (3.1.1) addressable (~> 2.8) childprocess (~> 5.0) logger (~> 1.6) @@ -391,7 +395,7 @@ GEM rexml link_header (0.0.8) lint_roller (1.1.0) - llhttp-ffi (0.5.0) + llhttp-ffi (0.5.1) ffi-compiler (~> 1.0) rake (~> 13.0) logger (1.6.6) @@ -413,13 +417,13 @@ GEM redis (>= 3.0.5) matrix (0.4.2) memory_profiler (1.1.0) - mime-types (3.6.0) + mime-types (3.6.2) logger mime-types-data (~> 3.2015) - mime-types-data (3.2025.0220) + mime-types-data (3.2025.0318) mini_mime (1.1.5) mini_portile2 (2.8.8) - minitest (5.25.4) + minitest (5.25.5) msgpack (1.8.0) multi_json (1.15.0) mutex_m (0.3.0) @@ -436,7 +440,7 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.3) + nokogiri (1.18.6) mini_portile2 (~> 2.8.2) racc (~> 1.4) oj (3.16.10) @@ -576,14 +580,14 @@ GEM ox (2.14.22) bigdecimal (>= 3.0) parallel (1.26.3) - parser (3.3.7.1) + parser (3.3.7.3) ast (~> 2.4.1) racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) pg (1.5.9) - pghero (3.6.1) + pghero (3.6.2) activerecord (>= 6.1) pp (0.6.2) prettyprint @@ -596,6 +600,7 @@ GEM net-smtp premailer (~> 1.7, >= 1.7.9) prettyprint (0.2.0) + prism (1.4.0) prometheus_exporter (2.2.0) webrick propshaft (1.1.0) @@ -729,7 +734,7 @@ GEM rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) rspec-support (3.13.2) - rubocop (1.74.0) + rubocop (1.75.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -737,11 +742,12 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.38.0, < 2.0) + rubocop-ast (>= 1.43.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.38.1) - parser (>= 3.3.1.0) + rubocop-ast (1.43.0) + parser (>= 3.3.7.2) + prism (~> 1.4) rubocop-capybara (2.22.1) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) @@ -785,7 +791,7 @@ GEM activerecord (>= 4.0.0) railties (>= 4.0.0) securerandom (0.4.1) - selenium-webdriver (4.29.1) + selenium-webdriver (4.30.1) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -826,7 +832,7 @@ GEM stoplight (4.1.1) redlock (~> 1.0) stringio (3.1.5) - strong_migrations (2.2.0) + strong_migrations (2.2.1) activerecord (>= 7) swd (2.0.3) activesupport (>= 3) @@ -862,7 +868,7 @@ GEM unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2025.1) + tzinfo-data (1.2025.2) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext @@ -917,6 +923,7 @@ DEPENDENCIES active_model_serializers (~> 0.10) addressable (~> 2.8) annotaterb (~> 4.13) + aws-sdk-core (< 3.216.0) aws-sdk-s3 (~> 1.123) better_errors (~> 2.9) binding_of_caller (~> 1.0) @@ -1069,4 +1076,4 @@ RUBY VERSION ruby 3.4.1p0 BUNDLED WITH - 2.6.5 + 2.6.6 diff --git a/app/controllers/api/v1/accounts/identity_proofs_controller.rb b/app/controllers/api/v1/accounts/identity_proofs_controller.rb index 48f293f47a..02a45e8758 100644 --- a/app/controllers/api/v1/accounts/identity_proofs_controller.rb +++ b/app/controllers/api/v1/accounts/identity_proofs_controller.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class Api::V1::Accounts::IdentityProofsController < Api::BaseController + include DeprecationConcern + + deprecate_api '2022-03-30' + before_action :require_user! before_action :set_account diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 6bef6a3768..46838aeb66 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -124,7 +124,7 @@ class Api::V1::AccountsController < Api::BaseController end def account_params - params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code) + params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code, :date_of_birth) end def invite diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb index c97e9720ad..f8d91c5f7f 100644 --- a/app/controllers/api/v1/filters_controller.rb +++ b/app/controllers/api/v1/filters_controller.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class Api::V1::FiltersController < Api::BaseController + include DeprecationConcern + + deprecate_api '2022-11-14' + before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show] before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show] before_action :require_user! diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb index 49da75ed28..e01267c000 100644 --- a/app/controllers/api/v1/instances_controller.rb +++ b/app/controllers/api/v1/instances_controller.rb @@ -1,15 +1,9 @@ # frozen_string_literal: true -class Api::V1::InstancesController < Api::BaseController - skip_before_action :require_authenticated_user!, unless: :limited_federation_mode? - skip_around_action :set_locale +class Api::V1::InstancesController < Api::V2::InstancesController + include DeprecationConcern - vary_by '' - - # Override `current_user` to avoid reading session cookies unless in limited federation mode - def current_user - super if limited_federation_mode? - end + deprecate_api '2022-11-14' def show cache_even_if_authenticated! diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb index 9ba1cef63c..918ec45beb 100644 --- a/app/controllers/api/v1/suggestions_controller.rb +++ b/app/controllers/api/v1/suggestions_controller.rb @@ -2,6 +2,9 @@ class Api::V1::SuggestionsController < Api::BaseController include Authorization + include DeprecationConcern + + deprecate_api '2021-05-16' before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index 10a3442344..f84f1c0252 100644 --- a/app/controllers/api/v1/trends/tags_controller.rb +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -1,12 +1,16 @@ # frozen_string_literal: true class Api::V1::Trends::TagsController < Api::BaseController + include DeprecationConcern + before_action :set_tags after_action :insert_pagination_headers DEFAULT_TAGS_LIMIT = 10 + deprecate_api '2022-03-30', only: :index, if: -> { request.path == '/api/v1/trends' } + def index cache_if_unauthenticated! render json: @tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@tags, current_user&.account_id) diff --git a/app/controllers/api/v2/instances_controller.rb b/app/controllers/api/v2/instances_controller.rb index 8346e28830..62adf95260 100644 --- a/app/controllers/api/v2/instances_controller.rb +++ b/app/controllers/api/v2/instances_controller.rb @@ -1,6 +1,16 @@ # frozen_string_literal: true -class Api::V2::InstancesController < Api::V1::InstancesController +class Api::V2::InstancesController < Api::BaseController + skip_before_action :require_authenticated_user!, unless: :limited_federation_mode? + skip_around_action :set_locale + + vary_by '' + + # Override `current_user` to avoid reading session cookies unless in limited federation mode + def current_user + super if limited_federation_mode? + end + def show cache_even_if_authenticated! render_with_cache json: InstancePresenter.new, serializer: REST::InstanceSerializer, root: 'instance' diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 6e34b6b627..0b6f5b3af4 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -62,7 +62,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController def configure_sign_up_params devise_parameter_sanitizer.permit(:sign_up) do |user_params| - user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password) + user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password, :date_of_birth) end end diff --git a/app/controllers/backups_controller.rb b/app/controllers/backups_controller.rb index 5df1af5f2f..076d19874b 100644 --- a/app/controllers/backups_controller.rb +++ b/app/controllers/backups_controller.rb @@ -9,13 +9,15 @@ class BackupsController < ApplicationController before_action :authenticate_user! before_action :set_backup + BACKUP_LINK_TIMEOUT = 1.hour.freeze + def download case Paperclip::Attachment.default_options[:storage] when :s3, :azure - redirect_to @backup.dump.expiring_url(10), allow_other_host: true + redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.to_i), allow_other_host: true when :fog if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present? - redirect_to @backup.dump.expiring_url(Time.now.utc + 10), allow_other_host: true + redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.from_now), allow_other_host: true else redirect_to full_asset_url(@backup.dump.url), allow_other_host: true end diff --git a/app/controllers/concerns/deprecation_concern.rb b/app/controllers/concerns/deprecation_concern.rb new file mode 100644 index 0000000000..ad8de724a1 --- /dev/null +++ b/app/controllers/concerns/deprecation_concern.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module DeprecationConcern + extend ActiveSupport::Concern + + class_methods do + def deprecate_api(date, sunset: nil, **kwargs) + deprecation_timestamp = "@#{date.to_datetime.to_i}" + sunset = sunset&.to_date&.httpdate + + before_action(**kwargs) do + response.headers['Deprecation'] = deprecation_timestamp + response.headers['Sunset'] = sunset if sunset + end + end + end +end diff --git a/app/helpers/admin/trends/statuses_helper.rb b/app/helpers/admin/trends/statuses_helper.rb index c7a59660cf..33da1f7216 100644 --- a/app/helpers/admin/trends/statuses_helper.rb +++ b/app/helpers/admin/trends/statuses_helper.rb @@ -2,11 +2,18 @@ module Admin::Trends::StatusesHelper def one_line_preview(status) - text = if status.local? - status.text.split("\n").first - else - Nokogiri::HTML5(status.text).css('html > body > *').first&.text - end + text = begin + if status.local? + status.text.split("\n").first + else + Nokogiri::HTML5(status.text).css('html > body > *').first&.text + end + rescue ArgumentError + # This can happen if one of the Nokogumbo limits is encountered + # Unfortunately, it does not use a more precise error class + # nor allows more graceful handling + '' + end return '' if text.blank? diff --git a/app/inputs/date_of_birth_input.rb b/app/inputs/date_of_birth_input.rb new file mode 100644 index 0000000000..131234b02e --- /dev/null +++ b/app/inputs/date_of_birth_input.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class DateOfBirthInput < SimpleForm::Inputs::Base + OPTIONS = [ + { autocomplete: 'bday-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' }.freeze, + { autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' }.freeze, + { autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' }.freeze, + ].freeze + + def input(wrapper_options = nil) + merged_input_options = merge_wrapper_options(input_html_options, wrapper_options) + merged_input_options[:inputmode] = 'numeric' + + values = (object.public_send(attribute_name) || '').split('.') + + safe_join(Array.new(3) do |index| + options = merged_input_options.merge(OPTIONS[index]).merge id: generate_id(index), 'aria-label': I18n.t("simple_form.labels.user.date_of_birth_#{index + 1}i"), value: values[index] + @builder.text_field("#{attribute_name}(#{index + 1}i)", options) + end) + end + + def label_target + "#{attribute_name}_1i" + end + + private + + def generate_id(index) + "#{object_name}_#{attribute_name}_#{index + 1}i" + end +end diff --git a/app/javascript/mastodon/actions/alerts.ts b/app/javascript/mastodon/actions/alerts.ts index a521f3ef35..4fd293e252 100644 --- a/app/javascript/mastodon/actions/alerts.ts +++ b/app/javascript/mastodon/actions/alerts.ts @@ -1,14 +1,11 @@ import { defineMessages } from 'react-intl'; -import type { MessageDescriptor } from 'react-intl'; + +import { createAction } from '@reduxjs/toolkit'; import { AxiosError } from 'axios'; import type { AxiosResponse } from 'axios'; -interface Alert { - title: string | MessageDescriptor; - message: string | MessageDescriptor; - values?: Record; -} +import type { Alert } from 'mastodon/models/alert'; interface ApiErrorResponse { error?: string; @@ -30,24 +27,13 @@ const messages = defineMessages({ }, }); -export const ALERT_SHOW = 'ALERT_SHOW'; -export const ALERT_DISMISS = 'ALERT_DISMISS'; -export const ALERT_CLEAR = 'ALERT_CLEAR'; -export const ALERT_NOOP = 'ALERT_NOOP'; +export const dismissAlert = createAction<{ key: number }>('alerts/dismiss'); -export const dismissAlert = (alert: Alert) => ({ - type: ALERT_DISMISS, - alert, -}); +export const clearAlerts = createAction('alerts/clear'); -export const clearAlert = () => ({ - type: ALERT_CLEAR, -}); +export const showAlert = createAction>('alerts/show'); -export const showAlert = (alert: Alert) => ({ - type: ALERT_SHOW, - alert, -}); +const ignoreAlert = createAction('alerts/ignore'); export const showAlertForError = (error: unknown, skipNotFound = false) => { if (error instanceof AxiosError && error.response) { @@ -56,7 +42,7 @@ export const showAlertForError = (error: unknown, skipNotFound = false) => { // Skip these errors as they are reflected in the UI if (skipNotFound && (status === 404 || status === 410)) { - return { type: ALERT_NOOP }; + return ignoreAlert(); } // Rate limit errors @@ -76,9 +62,9 @@ export const showAlertForError = (error: unknown, skipNotFound = false) => { }); } - // An aborted request, e.g. due to reloading the browser window, it not really error + // An aborted request, e.g. due to reloading the browser window, is not really an error if (error instanceof AxiosError && error.code === AxiosError.ECONNABORTED) { - return { type: ALERT_NOOP }; + return ignoreAlert(); } console.error(error); diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index f0663ded40..a41b058d2c 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -1,4 +1,9 @@ -import type { AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios'; +import type { + AxiosError, + AxiosResponse, + Method, + RawAxiosRequestHeaders, +} from 'axios'; import axios from 'axios'; import LinkHeader from 'http-link-header'; @@ -41,7 +46,7 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => { // eslint-disable-next-line import/no-default-export export default function api(withAuthorization = true) { - return axios.create({ + const instance = axios.create({ transitional: { clarifyTimeoutError: true, }, @@ -60,6 +65,22 @@ export default function api(withAuthorization = true) { }, ], }); + + instance.interceptors.response.use( + (response: AxiosResponse) => { + if (response.headers.deprecation) { + console.warn( + `Deprecated request: ${response.config.method} ${response.config.url}`, + ); + } + return response; + }, + (error: AxiosError) => { + return Promise.reject(error); + }, + ); + + return instance; } type RequestParamsOrData = Record; diff --git a/app/javascript/mastodon/components/alerts_controller.tsx b/app/javascript/mastodon/components/alerts_controller.tsx new file mode 100644 index 0000000000..26749fa103 --- /dev/null +++ b/app/javascript/mastodon/components/alerts_controller.tsx @@ -0,0 +1,105 @@ +import { useState, useEffect } from 'react'; + +import { useIntl } from 'react-intl'; +import type { IntlShape } from 'react-intl'; + +import classNames from 'classnames'; + +import { dismissAlert } from 'mastodon/actions/alerts'; +import type { + Alert, + TranslatableString, + TranslatableValues, +} from 'mastodon/models/alert'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +const formatIfNeeded = ( + intl: IntlShape, + message: TranslatableString, + values?: TranslatableValues, +) => { + if (typeof message === 'object') { + return intl.formatMessage(message, values); + } + + return message; +}; + +const Alert: React.FC<{ + alert: Alert; + dismissAfter: number; +}> = ({ + alert: { key, title, message, values, action, onClick }, + dismissAfter, +}) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const [active, setActive] = useState(false); + + useEffect(() => { + const setActiveTimeout = setTimeout(() => { + setActive(true); + }, 1); + + return () => { + clearTimeout(setActiveTimeout); + }; + }, []); + + useEffect(() => { + const dismissTimeout = setTimeout(() => { + setActive(false); + + // Allow CSS transition to finish before removing from the DOM + setTimeout(() => { + dispatch(dismissAlert({ key })); + }, 500); + }, dismissAfter); + + return () => { + clearTimeout(dismissTimeout); + }; + }, [dispatch, setActive, key, dismissAfter]); + + return ( +
+
+ {title && ( + + {formatIfNeeded(intl, title, values)} + + )} + + + {formatIfNeeded(intl, message, values)} + + + {action && ( + + )} +
+
+ ); +}; + +export const AlertsController: React.FC = () => { + const alerts = useAppSelector((state) => state.alerts); + + if (alerts.length === 0) { + return null; + } + + return ( +
+ {alerts.map((alert, idx) => ( + + ))} +
+ ); +}; diff --git a/app/javascript/mastodon/components/edited_timestamp/index.jsx b/app/javascript/mastodon/components/edited_timestamp/index.jsx index fbf14ec4bd..f8fa043259 100644 --- a/app/javascript/mastodon/components/edited_timestamp/index.jsx +++ b/app/javascript/mastodon/components/edited_timestamp/index.jsx @@ -6,6 +6,7 @@ import { FormattedMessage, injectIntl } from 'react-intl'; import { connect } from 'react-redux'; import { openModal } from 'mastodon/actions/modal'; +import { FormattedDateWrapper } from 'mastodon/components/formatted_date'; import InlineAccount from 'mastodon/components/inline_account'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; @@ -60,12 +61,12 @@ class EditedTimestamp extends PureComponent { }; render () { - const { timestamp, intl, statusId } = this.props; + const { timestamp, statusId } = this.props; return ( ); diff --git a/app/javascript/mastodon/components/formatted_date.tsx b/app/javascript/mastodon/components/formatted_date.tsx new file mode 100644 index 0000000000..cc927b0873 --- /dev/null +++ b/app/javascript/mastodon/components/formatted_date.tsx @@ -0,0 +1,26 @@ +import type { ComponentProps } from 'react'; + +import { FormattedDate } from 'react-intl'; + +export const FormattedDateWrapper = ( + props: ComponentProps & { className?: string }, +) => ( + + {(date) => ( + + )} + +); + +const tryIsoString = (date?: string | number | Date): string => { + if (!date) { + return ''; + } + try { + return new Date(date).toISOString(); + } catch { + return date.toString(); + } +}; diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx index fd8aa59b01..12cf381e5e 100644 --- a/app/javascript/mastodon/components/media_gallery.jsx +++ b/app/javascript/mastodon/components/media_gallery.jsx @@ -12,6 +12,7 @@ import { debounce } from 'lodash'; import { AltTextBadge } from 'mastodon/components/alt_text_badge'; import { Blurhash } from 'mastodon/components/blurhash'; +import { SpoilerButton } from 'mastodon/components/spoiler_button'; import { formatTime } from 'mastodon/features/video'; import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state'; @@ -38,6 +39,7 @@ class Item extends PureComponent { state = { loaded: false, + error: false, }; handleMouseEnter = (e) => { @@ -81,6 +83,10 @@ class Item extends PureComponent { this.setState({ loaded: true }); }; + handleImageError = () => { + this.setState({ error: true }); + }; + render () { const { attachment, lang, index, size, standalone, displayWidth, visible } = this.props; @@ -164,6 +170,7 @@ class Item extends PureComponent { lang={lang} style={{ objectPosition: `${x}% ${y}%` }} onLoad={this.handleImageLoad} + onError={this.handleImageError} /> ); @@ -199,7 +206,7 @@ class Item extends PureComponent { } return ( -
+
); } - if (uncached) { - spoilerButton = ( - - ); - } else if (!visible) { - spoilerButton = ( - - ); - } - const rowClass = (size === 5 || size === 6 || size === 9 || size === 10 || size === 11 || size === 12) ? 'media-gallery--row3' : (size === 7 || size === 8 || size === 13 || size === 14 || size === 15 || size === 16) ? 'media-gallery--row4' : 'media-gallery--row2'; @@ -366,11 +354,7 @@ class MediaGallery extends PureComponent {
{children} - {(!visible || uncached) && ( -
- {spoilerButton} -
- )} + {(!visible || uncached) && } {(visible && !uncached) && (
diff --git a/app/javascript/mastodon/components/spoiler_button.tsx b/app/javascript/mastodon/components/spoiler_button.tsx new file mode 100644 index 0000000000..bf84ffd04d --- /dev/null +++ b/app/javascript/mastodon/components/spoiler_button.tsx @@ -0,0 +1,89 @@ +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +interface Props { + hidden?: boolean; + sensitive: boolean; + uncached?: boolean; + matchedFilters?: string[]; + onClick: React.MouseEventHandler; +} + +export const SpoilerButton: React.FC = ({ + hidden = false, + sensitive, + uncached = false, + matchedFilters, + onClick, +}) => { + let warning; + let action; + + if (uncached) { + warning = ( + + ); + action = ( + + ); + } else if (matchedFilters) { + warning = ( + {chunks}, + }} + /> + ); + action = ( + + ); + } else if (sensitive) { + warning = ( + + ); + action = ( + + ); + } else { + warning = ( + + ); + action = ( + + ); + } + + return ( +
+ +
+ ); +}; diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 5c95cf46e1..0efea48f87 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -77,7 +77,7 @@ export const defaultMediaVisibility = (status) => { status = status.get('reblog'); } - return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all'); + return !status.get('matched_media_filters') && (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all'); }; const messages = defineMessages({ @@ -496,6 +496,7 @@ class Status extends ImmutablePureComponent { defaultWidth={this.props.cachedMediaWidth} visible={this.state.showMedia} onToggleVisibility={this.handleToggleMediaVisibility} + matchedFilters={status.get('matched_media_filters')} /> )} @@ -524,6 +525,7 @@ class Status extends ImmutablePureComponent { blurhash={attachment.get('blurhash')} visible={this.state.showMedia} onToggleVisibility={this.handleToggleMediaVisibility} + matchedFilters={status.get('matched_media_filters')} /> )} @@ -548,6 +550,7 @@ class Status extends ImmutablePureComponent { deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined} visible={this.state.showMedia} onToggleVisibility={this.handleToggleMediaVisibility} + matchedFilters={status.get('matched_media_filters')} /> )} diff --git a/app/javascript/mastodon/features/account/components/account_note.jsx b/app/javascript/mastodon/features/account/components/account_note.jsx index e736e7ad64..df7312eafc 100644 --- a/app/javascript/mastodon/features/account/components/account_note.jsx +++ b/app/javascript/mastodon/features/account/components/account_note.jsx @@ -4,7 +4,6 @@ import { PureComponent } from 'react'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { is } from 'immutable'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Textarea from 'react-textarea-autosize'; @@ -49,7 +48,7 @@ class InlineAlert extends PureComponent { class AccountNote extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.record.isRequired, + accountId: PropTypes.string.isRequired, value: PropTypes.string, onSave: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, @@ -66,7 +65,7 @@ class AccountNote extends ImmutablePureComponent { } UNSAFE_componentWillReceiveProps (nextProps) { - const accountWillChange = !is(this.props.account, nextProps.account); + const accountWillChange = !is(this.props.accountId, nextProps.accountId); const newState = {}; if (accountWillChange && this._isDirty()) { @@ -102,10 +101,10 @@ class AccountNote extends ImmutablePureComponent { if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { e.preventDefault(); - this._save(); - if (this.textarea) { this.textarea.blur(); + } else { + this._save(); } } else if (e.keyCode === 27) { e.preventDefault(); @@ -141,21 +140,21 @@ class AccountNote extends ImmutablePureComponent { } render () { - const { account, intl } = this.props; + const { accountId, intl } = this.props; const { value, saved } = this.state; - if (!account) { + if (!accountId) { return null; } return (
-