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 => (
- -
-
{rule.get('text')}
- {rule.get('hint').length > 0 && ({rule.get('hint')}
)}
-
- ))}
+ {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 (
+ -
+
{text}
+ {hint.length > 0 && ({hint}
)}
+
+ )})}
))}
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