diff --git a/app/controllers/admin/rules_controller.rb b/app/controllers/admin/rules_controller.rb index b9331e7e98..58d52e6291 100644 --- a/app/controllers/admin/rules_controller.rb +++ b/app/controllers/admin/rules_controller.rb @@ -7,7 +7,7 @@ module Admin def index authorize :rule, :index? - @rules = Rule.ordered + @rules = Rule.ordered.includes(:translations) end def new @@ -27,7 +27,6 @@ module Admin if @rule.save redirect_to admin_rules_path else - @rules = Rule.ordered render :new end end @@ -74,7 +73,7 @@ module Admin def resource_params params - .expect(rule: [:text, :hint, :priority]) + .expect(rule: [:text, :hint, :priority, translations_attributes: [[:id, :language, :text, :hint, :_destroy]]]) end end end diff --git a/app/controllers/api/v1/instances/rules_controller.rb b/app/controllers/api/v1/instances/rules_controller.rb index 3930eec0dd..2b6e534875 100644 --- a/app/controllers/api/v1/instances/rules_controller.rb +++ b/app/controllers/api/v1/instances/rules_controller.rb @@ -18,6 +18,6 @@ class Api::V1::Instances::RulesController < Api::V1::Instances::BaseController private def set_rules - @rules = Rule.ordered + @rules = Rule.ordered.includes(:translations) end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 0b6f5b3af4..973724cf7c 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -126,7 +126,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController end def set_rules - @rules = Rule.ordered + @rules = Rule.ordered.includes(:translations) end def require_rules_acceptance! diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 34e84506f0..f2ea16a952 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -44,6 +44,7 @@ const severityMessages = { const mapStateToProps = state => ({ server: state.getIn(['server', 'server']), + locale: state.getIn(['meta', 'locale']), extendedDescription: state.getIn(['server', 'extendedDescription']), domainBlocks: state.getIn(['server', 'domainBlocks']), }); @@ -91,6 +92,7 @@ class About extends PureComponent { static propTypes = { server: ImmutablePropTypes.map, + locale: ImmutablePropTypes.string, extendedDescription: ImmutablePropTypes.map, domainBlocks: ImmutablePropTypes.contains({ isLoading: PropTypes.bool, @@ -114,7 +116,7 @@ class About extends PureComponent { }; render () { - const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props; + const { multiColumn, intl, server, extendedDescription, domainBlocks, locale } = this.props; const isLoading = server.get('isLoading'); return ( @@ -168,12 +170,15 @@ class About extends PureComponent {

) : (
    - {server.get('rules').map(rule => ( -
  1. -
    {rule.get('text')}
    - {rule.get('hint').length > 0 && (
    {rule.get('hint')}
    )} -
  2. - ))} + {server.get('rules').map(rule => { + const text = rule.getIn(['translations', locale, 'text']) || rule.get('text'); + const hint = rule.getIn(['translations', locale, 'hint']) || rule.get('hint'); + return ( +
  3. +
    {text}
    + {hint.length > 0 && (
    {hint}
    )} +
  4. + )})}
))} diff --git a/app/javascript/mastodon/features/report/rules.jsx b/app/javascript/mastodon/features/report/rules.jsx index 621f140adb..dff3769379 100644 --- a/app/javascript/mastodon/features/report/rules.jsx +++ b/app/javascript/mastodon/features/report/rules.jsx @@ -12,6 +12,7 @@ import Option from './components/option'; const mapStateToProps = state => ({ rules: state.getIn(['server', 'server', 'rules']), + locale: state.getIn(['meta', 'locale']), }); class Rules extends PureComponent { @@ -19,6 +20,7 @@ class Rules extends PureComponent { static propTypes = { onNextStep: PropTypes.func.isRequired, rules: ImmutablePropTypes.list, + locale: PropTypes.string, selectedRuleIds: ImmutablePropTypes.set.isRequired, onToggle: PropTypes.func.isRequired, }; @@ -34,7 +36,7 @@ class Rules extends PureComponent { }; render () { - const { rules, selectedRuleIds } = this.props; + const { rules, locale, selectedRuleIds } = this.props; return ( <> @@ -49,7 +51,7 @@ class Rules extends PureComponent { value={item.get('id')} checked={selectedRuleIds.includes(item.get('id'))} onToggle={this.handleRulesToggle} - label={item.get('text')} + label={item.getIn(['translations', locale, 'text']) || item.get('text')} multiple /> ))} diff --git a/app/models/rule.rb b/app/models/rule.rb index 3033a2b03d..8f36f11abb 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -19,6 +19,9 @@ class Rule < ApplicationRecord self.discard_column = :deleted_at + has_many :translations, inverse_of: :rule, class_name: 'RuleTranslation', dependent: :destroy + accepts_nested_attributes_for :translations, reject_if: :all_blank, allow_destroy: true + validates :text, presence: true, length: { maximum: TEXT_SIZE_LIMIT } scope :ordered, -> { kept.order(priority: :asc, id: :asc) } @@ -36,4 +39,9 @@ class Rule < ApplicationRecord end end end + + def translation_for(locale) + @cached_translations ||= {} + @cached_translations[locale] ||= translations.find_by(language: locale) || RuleTranslation.new(language: locale, text: text, hint: hint) + end end diff --git a/app/models/rule_translation.rb b/app/models/rule_translation.rb new file mode 100644 index 0000000000..99991b2ee1 --- /dev/null +++ b/app/models/rule_translation.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: rule_translations +# +# id :bigint(8) not null, primary key +# hint :text default(""), not null +# language :string not null +# text :text default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# rule_id :bigint(8) not null +# +class RuleTranslation < ApplicationRecord + belongs_to :rule + + validates :language, presence: true, uniqueness: { scope: :rule_id } + validates :text, presence: true, length: { maximum: Rule::TEXT_SIZE_LIMIT } +end diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb index 92415a6903..6923f565ef 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -47,7 +47,7 @@ class InstancePresenter < ActiveModelSerializers::Model end def rules - Rule.ordered + Rule.ordered.includes(:translations) end def user_count diff --git a/app/serializers/rest/rule_serializer.rb b/app/serializers/rest/rule_serializer.rb index 9e2bcda15e..3ce2d02e66 100644 --- a/app/serializers/rest/rule_serializer.rb +++ b/app/serializers/rest/rule_serializer.rb @@ -1,9 +1,15 @@ # frozen_string_literal: true class REST::RuleSerializer < ActiveModel::Serializer - attributes :id, :text, :hint + attributes :id, :text, :hint, :translations def id object.id.to_s end + + def translations + object.translations.to_h do |translation| + [translation.language, { text: translation.text, hint: translation.hint }] + end + end end diff --git a/app/views/admin/rules/_translation_fields.html.haml b/app/views/admin/rules/_translation_fields.html.haml new file mode 100644 index 0000000000..bf8d817224 --- /dev/null +++ b/app/views/admin/rules/_translation_fields.html.haml @@ -0,0 +1,27 @@ +%tr.nested-fields + %td + .fields-row + .fields-row__column.fields-group + = f.input :language, + collection: ui_languages, + include_blank: false, + label_method: ->(locale) { native_locale_name(locale) } + + .fields-row__column.fields-group + = f.hidden_field :id if f.object&.persisted? # Required so Rails doesn't put the field outside of the + = link_to_remove_association(f, class: 'table-action-link') do + = safe_join([material_symbol('close'), t('filters.index.delete')]) + + .fields-group + = f.input :text, + label: I18n.t('simple_form.labels.rule.text'), + hint: I18n.t('simple_form.hints.rule.text'), + input_html: { lang: f.object&.language }, + wrapper: :with_block_label + + .fields-group + = f.input :hint, + label: I18n.t('simple_form.labels.rule.hint'), + hint: I18n.t('simple_form.hints.rule.hint'), + input_html: { lang: f.object&.language }, + wrapper: :with_block_label diff --git a/app/views/admin/rules/edit.html.haml b/app/views/admin/rules/edit.html.haml index 9e3c915812..b64a27d751 100644 --- a/app/views/admin/rules/edit.html.haml +++ b/app/views/admin/rules/edit.html.haml @@ -6,5 +6,26 @@ = render form + %hr.spacer/ + + %h4= t('admin.rules.translations') + + %p.hint= t('admin.rules.translations_explanation') + + .table-wrapper + %table.table.keywords-table + %thead + %tr + %th= t('admin.rules.translation') + %th + %tbody + = form.simple_fields_for :translations do |translation| + = render 'translation_fields', f: translation + %tfoot + %tr + %td{ colspan: 3 } + = link_to_add_association form, :translations, class: 'table-action-link', partial: 'translation_fields', 'data-association-insertion-node': '.keywords-table tbody', 'data-association-insertion-method': 'append' do + = safe_join([material_symbol('add'), t('admin.rules.add_translation')]) + .actions = form.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/auth/registrations/rules.html.haml b/app/views/auth/registrations/rules.html.haml index 4b0159e862..59e7c9072f 100644 --- a/app/views/auth/registrations/rules.html.haml +++ b/app/views/auth/registrations/rules.html.haml @@ -18,10 +18,11 @@ %ol.rules-list - @rules.each do |rule| + - translation = rule.translation_for(I18n.locale.to_s) %li %button{ type: 'button', aria: { expanded: 'false' } } - .rules-list__text= rule.text - .rules-list__hint= rule.hint + .rules-list__text= translation.text + .rules-list__hint= translation.hint .stacked-actions - accept_path = @invite_code.present? ? public_invite_url(invite_code: @invite_code, accept: @accept_token) : new_user_registration_path(accept: @accept_token) diff --git a/config/locales/en.yml b/config/locales/en.yml index dad46d9d51..2ab8f015d3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -786,6 +786,7 @@ en: title: Roles rules: add_new: Add rule + add_translation: Add translation delete: Delete description_html: While most claim to have read and agree to the terms of service, usually people do not read through until after a problem arises. Make it easier to see your server's rules at a glance by providing them in a flat bullet point list. Try to keep individual rules short and simple, but try not to split them up into many separate items either. edit: Edit rule @@ -793,6 +794,9 @@ en: move_down: Move down move_up: Move up title: Server rules + translation: Translation + translations: Translations + translations_explanation: You can optionally add translations for the rules. The default value will be shown if no translated version is available. Please always ensure any provided translation is in sync with the default value. settings: about: manage_rules: Manage server rules diff --git a/db/migrate/20250520204643_create_rule_translations.rb b/db/migrate/20250520204643_create_rule_translations.rb new file mode 100644 index 0000000000..cf696e769d --- /dev/null +++ b/db/migrate/20250520204643_create_rule_translations.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateRuleTranslations < ActiveRecord::Migration[8.0] + def change + create_table :rule_translations do |t| + t.text :text, null: false, default: '' + t.text :hint, null: false, default: '' + t.string :language, null: false + t.references :rule, null: false, foreign_key: { on_delete: :cascade }, index: false + + t.timestamps + end + + add_index :rule_translations, [:rule_id, :language], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 6cb2a667a7..6c97151bb9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_05_20_192024) do +ActiveRecord::Schema[8.0].define(version: 2025_05_20_204643) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -962,6 +962,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_20_192024) do t.index ["target_account_id"], name: "index_reports_on_target_account_id" end + create_table "rule_translations", force: :cascade do |t| + t.text "text", default: "", null: false + t.text "hint", default: "", null: false + t.string "language", null: false + t.bigint "rule_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["rule_id", "language"], name: "index_rule_translations_on_rule_id_and_language", unique: true + end + create_table "rules", force: :cascade do |t| t.integer "priority", default: 0, null: false t.datetime "deleted_at", precision: nil @@ -1406,6 +1416,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_20_192024) do add_foreign_key "reports", "accounts", column: "target_account_id", name: "fk_eb37af34f0", on_delete: :cascade add_foreign_key "reports", "accounts", name: "fk_4b81f7522c", on_delete: :cascade add_foreign_key "reports", "oauth_applications", column: "application_id", on_delete: :nullify + add_foreign_key "rule_translations", "rules", on_delete: :cascade add_foreign_key "scheduled_statuses", "accounts", on_delete: :cascade add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade diff --git a/spec/fabricators/rule_translation_fabricator.rb b/spec/fabricators/rule_translation_fabricator.rb new file mode 100644 index 0000000000..de29e47e7e --- /dev/null +++ b/spec/fabricators/rule_translation_fabricator.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +Fabricator(:rule_translation) do + text 'MyText' + hint 'MyText' + language 'en' + rule { Fabricate.build(:rule) } +end diff --git a/spec/serializers/rest/rule_serializer_spec.rb b/spec/serializers/rest/rule_serializer_spec.rb index 4d801e77d3..9d2889c9fc 100644 --- a/spec/serializers/rest/rule_serializer_spec.rb +++ b/spec/serializers/rest/rule_serializer_spec.rb @@ -11,7 +11,8 @@ RSpec.describe REST::RuleSerializer do it 'returns expected values' do expect(subject) .to include( - 'id' => be_a(String).and(eq('123')) + 'id' => be_a(String).and(eq('123')), + 'translations' => be_a(Hash) ) end end