diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index f1945f8a648083..137bebc599d9bb 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -70,7 +70,7 @@ services: hard: -1 libretranslate: - image: libretranslate/libretranslate:v1.4.0 + image: libretranslate/libretranslate:v1.4.1 restart: unless-stopped volumes: - lt-data:/home/libretranslate/.local diff --git a/.env.test b/.env.test index def5fbd8d37279..e247a2e952ebcf 100644 --- a/.env.test +++ b/.env.test @@ -1,5 +1,5 @@ -# Node.js -NODE_ENV=tests +# In test, compile the NodeJS code as if we are in production +NODE_ENV=production # Federation LOCAL_DOMAIN=cb6e6126.ngrok.io LOCAL_HTTPS=true diff --git a/.haml-lint.yml b/.haml-lint.yml index d1ed30b260c06a..8cfcaec8d93fec 100644 --- a/.haml-lint.yml +++ b/.haml-lint.yml @@ -12,3 +12,5 @@ linters: enabled: true MiddleDot: enabled: true + LineLength: + max: 320 diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml index abf4295585bf89..1328a52f02a748 100644 --- a/.haml-lint_todo.yml +++ b/.haml-lint_todo.yml @@ -1,17 +1,33 @@ # This configuration was generated by # `haml-lint --auto-gen-config` -# on 2023-10-25 08:29:48 -0400 using Haml-Lint version 0.51.0. +# on 2023-10-26 09:32:34 -0400 using Haml-Lint version 0.51.0. # The point is for the user to remove these configuration records # one by one as the lints are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of Haml-Lint, may require this file to be generated again. linters: - # Offense count: 945 + # Offense count: 16 LineLength: - enabled: false + exclude: + - 'app/views/admin/account_actions/new.html.haml' + - 'app/views/admin/accounts/index.html.haml' + - 'app/views/admin/ip_blocks/new.html.haml' + - 'app/views/admin/roles/_form.html.haml' + - 'app/views/admin/settings/discovery/show.html.haml' + - 'app/views/auth/registrations/edit.html.haml' + - 'app/views/auth/registrations/new.html.haml' + - 'app/views/filters/_filter_fields.html.haml' + - 'app/views/media/player.html.haml' + - 'app/views/settings/applications/_fields.html.haml' + - 'app/views/settings/imports/index.html.haml' + - 'app/views/settings/preferences/appearance/show.html.haml' + - 'app/views/settings/preferences/notifications/show.html.haml' + - 'app/views/settings/preferences/other/show.html.haml' + - 'app/views/settings/preferences/reaching/show.html.haml' + - 'app/views/settings/profiles/show.html.haml' - # Offense count: 10 + # Offense count: 9 RuboCop: exclude: - 'app/views/admin/accounts/_buttons.html.haml' diff --git a/.rubocop.yml b/.rubocop.yml index 786a724f0c5ce6..aa503162db33e3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -27,7 +27,7 @@ AllCops: - 'node_modules/**/*' - 'Vagrantfile' - 'vendor/**/*' - - 'lib/json_ld/*' # Generated files + - 'config/initializers/json_ld*' # Generated files - 'lib/mastodon/migration_helpers.rb' # Vendored from GitLab - 'lib/templates/**/*' diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index bb4638ad28c362..566663f0052632 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -130,11 +130,6 @@ RSpec/InstanceVariable: RSpec/LetSetup: Exclude: - - 'spec/controllers/admin/accounts_controller_spec.rb' - - 'spec/controllers/admin/action_logs_controller_spec.rb' - - 'spec/controllers/admin/instances_controller_spec.rb' - - 'spec/controllers/admin/reports/actions_controller_spec.rb' - - 'spec/controllers/admin/statuses_controller_spec.rb' - 'spec/controllers/api/v1/accounts/statuses_controller_spec.rb' - 'spec/controllers/api/v1/filters_controller_spec.rb' - 'spec/controllers/api/v2/admin/accounts_controller_spec.rb' diff --git a/Gemfile b/Gemfile index a01f35f07f0f4d..139c3fb5f713d0 100644 --- a/Gemfile +++ b/Gemfile @@ -23,7 +23,7 @@ gem 'blurhash', '~> 0.1' gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.8' -gem 'bootsnap', '~> 1.16.0', require: false +gem 'bootsnap', '~> 1.17.0', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'chewy', '~> 7.3' @@ -88,7 +88,7 @@ gem 'simple-navigation', '~> 4.4' gem 'simple_form', '~> 5.2' gem 'sprockets-rails', '~> 3.4', require: 'sprockets/railtie' gem 'stoplight', '~> 3.0.1' -gem 'strong_migrations', '~> 0.8' +gem 'strong_migrations', '1.3.0' gem 'tty-prompt', '~> 0.23', require: false gem 'twitter-text', '~> 3.1.0' gem 'tzinfo-data', '~> 1.2023' diff --git a/Gemfile.lock b/Gemfile.lock index 7d6b2e9f43b912..348deaab5c1af8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -171,7 +171,7 @@ GEM binding_of_caller (1.0.0) debug_inspector (>= 0.0.1) blurhash (0.1.7) - bootsnap (1.16.0) + bootsnap (1.17.0) msgpack (~> 1.2) brakeman (6.0.1) browser (5.3.1) @@ -455,7 +455,7 @@ GEM mini_mime (1.1.5) mini_portile2 (2.8.4) minitest (5.20.0) - msgpack (1.7.1) + msgpack (1.7.2) multi_json (1.15.0) multipart-post (2.3.0) mutex_m (0.1.2) @@ -631,7 +631,7 @@ GEM rspec-support (~> 3.12.0) rspec-github (2.4.0) rspec-core (~> 3.0) - rspec-mocks (3.12.5) + rspec-mocks (3.12.6) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) rspec-rails (6.0.3) @@ -644,7 +644,7 @@ GEM rspec-support (~> 3.12) rspec-retry (0.6.2) rspec-core (> 3.3) - rspec-sidekiq (4.0.1) + rspec-sidekiq (4.1.0) rspec-core (~> 3.0) rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) @@ -742,7 +742,7 @@ GEM stoplight (3.0.2) redlock (~> 1.0) stringio (3.0.8) - strong_migrations (0.8.0) + strong_migrations (1.3.0) activerecord (>= 5.2) swd (1.3.0) activesupport (>= 3) @@ -835,7 +835,7 @@ DEPENDENCIES better_errors (~> 2.9) binding_of_caller (~> 1.0) blurhash (~> 0.1) - bootsnap (~> 1.16.0) + bootsnap (~> 1.17.0) brakeman (~> 6.0) browser bundler-audit (~> 0.9) @@ -945,7 +945,7 @@ DEPENDENCIES sprockets-rails (~> 3.4) stackprof stoplight (~> 3.0.1) - strong_migrations (~> 0.8) + strong_migrations (= 1.3.0) test-prof thor (~> 1.2) tty-prompt (~> 0.23) diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index edacbd5adc14fa..96e53274e489e7 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -33,7 +33,7 @@ def create # Disallow accidentally downgrading a domain block if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block) - @domain_block.save + @domain_block.validate flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe @domain_block.errors.delete(:domain) return render :new diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb index 55ebe1bd649cb9..2dfe5e263af05a 100644 --- a/app/controllers/concerns/cache_concern.rb +++ b/app/controllers/concerns/cache_concern.rb @@ -92,18 +92,10 @@ def serialize_record(record) arguments end - if Rails.gem_version >= Gem::Version.new('7.0') - def attributes_for_database(record) - attributes = record.attributes_for_database - attributes.transform_values! { |attr| attr.is_a?(::ActiveModel::Type::Binary::Data) ? attr.to_s : attr } - attributes - end - else - def attributes_for_database(record) - attributes = record.instance_variable_get(:@attributes).send(:attributes).transform_values(&:value_for_database) - attributes.transform_values! { |attr| attr.is_a?(::ActiveModel::Type::Binary::Data) ? attr.to_s : attr } - attributes - end + def attributes_for_database(record) + attributes = record.attributes_for_database + attributes.transform_values! { |attr| attr.is_a?(::ActiveModel::Type::Binary::Data) ? attr.to_s : attr } + attributes end def deserialize_record(class_name, attributes_from_database, new_record = false) # rubocop:disable Style/OptionalBooleanParameter diff --git a/app/javascript/mastodon/components/autosuggest_textarea.jsx b/app/javascript/mastodon/components/autosuggest_textarea.jsx index 230e4f65721077..4d173af59d47c9 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.jsx +++ b/app/javascript/mastodon/components/autosuggest_textarea.jsx @@ -1,9 +1,9 @@ import PropTypes from 'prop-types'; +import { useCallback, useRef, useState, useEffect, forwardRef } from 'react'; import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; import Textarea from 'react-textarea-autosize'; @@ -37,54 +37,46 @@ const textAtCursorMatchesToken = (str, caretPosition) => { } }; -export default class AutosuggestTextarea extends ImmutablePureComponent { - - static propTypes = { - value: PropTypes.string, - suggestions: ImmutablePropTypes.list, - disabled: PropTypes.bool, - placeholder: PropTypes.string, - onSuggestionSelected: PropTypes.func.isRequired, - onSuggestionsClearRequested: PropTypes.func.isRequired, - onSuggestionsFetchRequested: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onKeyUp: PropTypes.func, - onKeyDown: PropTypes.func, - onPaste: PropTypes.func.isRequired, - autoFocus: PropTypes.bool, - lang: PropTypes.string, - }; - - static defaultProps = { - autoFocus: true, - }; - - state = { - suggestionsHidden: true, - focused: false, - selectedSuggestion: 0, - lastToken: null, - tokenStart: 0, - }; - - onChange = (e) => { +const AutosuggestTextarea = forwardRef(({ + value, + suggestions, + disabled, + placeholder, + onSuggestionSelected, + onSuggestionsClearRequested, + onSuggestionsFetchRequested, + onChange, + onKeyUp, + onKeyDown, + onPaste, + onFocus, + autoFocus = true, + lang, + children, +}, textareaRef) => { + + const [suggestionsHidden, setSuggestionsHidden] = useState(true); + const [selectedSuggestion, setSelectedSuggestion] = useState(0); + const lastTokenRef = useRef(null); + const tokenStartRef = useRef(0); + + const handleChange = useCallback((e) => { const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); - if (token !== null && this.state.lastToken !== token) { - this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); - this.props.onSuggestionsFetchRequested(token); + if (token !== null && lastTokenRef.current !== token) { + tokenStartRef.current = tokenStart; + lastTokenRef.current = token; + setSelectedSuggestion(0); + onSuggestionsFetchRequested(token); } else if (token === null) { - this.setState({ lastToken: null }); - this.props.onSuggestionsClearRequested(); + lastTokenRef.current = null; + onSuggestionsClearRequested(); } - this.props.onChange(e); - }; - - onKeyDown = (e) => { - const { suggestions, disabled } = this.props; - const { selectedSuggestion, suggestionsHidden } = this.state; + onChange(e); + }, [onSuggestionsFetchRequested, onSuggestionsClearRequested, onChange, setSelectedSuggestion]); + const handleKeyDown = useCallback((e) => { if (disabled) { e.preventDefault(); return; @@ -102,80 +94,75 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { document.querySelector('.ui').parentElement.focus(); } else { e.preventDefault(); - this.setState({ suggestionsHidden: true }); + setSuggestionsHidden(true); } break; case 'ArrowDown': if (suggestions.size > 0 && !suggestionsHidden) { e.preventDefault(); - this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); + setSelectedSuggestion(Math.min(selectedSuggestion + 1, suggestions.size - 1)); } break; case 'ArrowUp': if (suggestions.size > 0 && !suggestionsHidden) { e.preventDefault(); - this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); + setSelectedSuggestion(Math.max(selectedSuggestion - 1, 0)); } break; case 'Enter': case 'Tab': // Select suggestion - if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { + if (lastTokenRef.current !== null && suggestions.size > 0 && !suggestionsHidden) { e.preventDefault(); e.stopPropagation(); - this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); + onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestions.get(selectedSuggestion)); } break; } - if (e.defaultPrevented || !this.props.onKeyDown) { + if (e.defaultPrevented || !onKeyDown) { return; } - this.props.onKeyDown(e); - }; + onKeyDown(e); + }, [disabled, suggestions, suggestionsHidden, selectedSuggestion, setSelectedSuggestion, setSuggestionsHidden, onSuggestionSelected, onKeyDown]); - onBlur = () => { - this.setState({ suggestionsHidden: true, focused: false }); - }; + const handleBlur = useCallback(() => { + setSuggestionsHidden(true); + }, [setSuggestionsHidden]); - onFocus = (e) => { - this.setState({ focused: true }); - if (this.props.onFocus) { - this.props.onFocus(e); + const handleFocus = useCallback((e) => { + if (onFocus) { + onFocus(e); } - }; + }, [onFocus]); - onSuggestionClick = (e) => { - const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); + const handleSuggestionClick = useCallback((e) => { + const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index')); e.preventDefault(); - this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); - this.textarea.focus(); - }; + onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestion); + textareaRef.current?.focus(); + }, [suggestions, onSuggestionSelected, textareaRef]); - UNSAFE_componentWillReceiveProps (nextProps) { - if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { - this.setState({ suggestionsHidden: false }); - } - } - - setTextarea = (c) => { - this.textarea = c; - }; - - onPaste = (e) => { + const handlePaste = useCallback((e) => { if (e.clipboardData && e.clipboardData.files.length === 1) { - this.props.onPaste(e.clipboardData.files); + onPaste(e.clipboardData.files); e.preventDefault(); } - }; + }, [onPaste]); + + // Show the suggestions again whenever they change and the textarea is focused + useEffect(() => { + if (suggestions.size > 0 && textareaRef.current === document.activeElement) { + setSuggestionsHidden(false); + } + }, [suggestions, textareaRef, setSuggestionsHidden]); - renderSuggestion = (suggestion, i) => { - const { selectedSuggestion } = this.state; + const renderSuggestion = (suggestion, i) => { let inner, key; if (suggestion.type === 'emoji') { @@ -190,50 +177,64 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } return ( -
+
{inner}
); }; - render () { - const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props; - const { suggestionsHidden } = this.state; - - return [ -
-
-