about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2022-09-20 23:51:21 +0200
committerGitHub <noreply@github.com>2022-09-20 23:51:21 +0200
commit50948b46aabc0756d85bc6641f0bd3bcc09bf7d4 (patch)
tree19dd9e761aec2a33d6bc3c11b47673da40cd965b
parent882e54c78678bd4247d70fe5b04571543769bcee (diff)
Add ability to filter followed accounts' posts by language (#19095)
-rw-r--r--app/controllers/api/v1/accounts_controller.rb6
-rw-r--r--app/javascript/mastodon/features/account/components/header.js5
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js6
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js8
-rw-r--r--app/javascript/mastodon/features/subscribed_languages_modal/index.js121
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json25
-rw-r--r--app/javascript/mastodon/locales/en.json4
-rw-r--r--app/lib/feed_manager.rb2
-rw-r--r--app/models/concerns/account_interactions.rb23
-rw-r--r--app/models/export.rb4
-rw-r--r--app/models/follow.rb4
-rw-r--r--app/models/follow_request.rb4
-rw-r--r--app/serializers/rest/relationship_serializer.rb7
-rw-r--r--app/services/follow_service.rb13
-rw-r--r--app/services/import_service.rb2
-rw-r--r--app/validators/language_validator.rb21
-rw-r--r--app/workers/refollow_worker.rb7
-rw-r--r--app/workers/unfollow_follow_worker.rb9
-rw-r--r--db/migrate/20220829192633_add_languages_to_follows.rb5
-rw-r--r--db/migrate/20220829192658_add_languages_to_follow_requests.rb5
-rw-r--r--db/schema.rb4
-rw-r--r--spec/controllers/api/v1/accounts_controller_spec.rb11
-rw-r--r--spec/controllers/settings/exports/following_accounts_controller_spec.rb2
-rw-r--r--spec/lib/feed_manager_spec.rb12
-rw-r--r--spec/models/concerns/account_interactions_spec.rb2
-rw-r--r--spec/models/export_spec.rb4
-rw-r--r--spec/models/follow_request_spec.rb2
-rw-r--r--spec/services/follow_service_spec.rb13
-rw-r--r--spec/workers/refollow_worker_spec.rb4
30 files changed, 298 insertions, 39 deletions
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 5537cc9b0..be84720aa 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -30,12 +30,12 @@ class Api::V1::AccountsController < Api::BaseController
     self.response_body = Oj.dump(response.body)
     self.status        = response.status
   rescue ActiveRecord::RecordInvalid => e
-    render json: ValidationErrorFormatter.new(e, :'account.username' => :username, :'invite_request.text' => :reason).as_json, status: :unprocessable_entity
+    render json: ValidationErrorFormatter.new(e, 'account.username': :username, 'invite_request.text': :reason).as_json, status: :unprocessable_entity
   end
 
   def follow
-    follow  = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, with_rate_limit: true)
-    options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify? } }, requested_map: { @account.id => false } }
+    follow  = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, languages: params.key?(:languages) ? params[:languages] : nil, with_rate_limit: true)
+    options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify?, languages: follow.languages } }, requested_map: { @account.id => false } }
 
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(**options)
   end
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 1ad9341c7..8f2753c35 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -51,6 +51,7 @@ const messages = defineMessages({
   unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
   add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
   admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
+  languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' },
 });
 
 const dateFormatOptions = {
@@ -85,6 +86,7 @@ class Header extends ImmutablePureComponent {
     onEndorseToggle: PropTypes.func.isRequired,
     onAddToList: PropTypes.func.isRequired,
     onEditAccountNote: PropTypes.func.isRequired,
+    onChangeLanguages: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     domain: PropTypes.string.isRequired,
     hidden: PropTypes.bool,
@@ -212,6 +214,9 @@ class Header extends ImmutablePureComponent {
           } else {
             menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
           }
+
+          menu.push({ text: intl.formatMessage(messages.languages), action: this.props.onChangeLanguages });
+          menu.push(null);
         }
 
         menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index fab0bc597..f9838442f 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -22,6 +22,7 @@ export default class Header extends ImmutablePureComponent {
     onUnblockDomain: PropTypes.func.isRequired,
     onEndorseToggle: PropTypes.func.isRequired,
     onAddToList: PropTypes.func.isRequired,
+    onChangeLanguages: PropTypes.func.isRequired,
     hideTabs: PropTypes.bool,
     domain: PropTypes.string.isRequired,
     hidden: PropTypes.bool,
@@ -91,6 +92,10 @@ export default class Header extends ImmutablePureComponent {
     this.props.onEditAccountNote(this.props.account);
   }
 
+  handleChangeLanguages = () => {
+    this.props.onChangeLanguages(this.props.account);
+  }
+
   render () {
     const { account, hidden, hideTabs } = this.props;
 
@@ -117,6 +122,7 @@ export default class Header extends ImmutablePureComponent {
           onEndorseToggle={this.handleEndorseToggle}
           onAddToList={this.handleAddToList}
           onEditAccountNote={this.handleEditAccountNote}
+          onChangeLanguages={this.handleChangeLanguages}
           domain={this.props.domain}
           hidden={hidden}
         />
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index 371794dd7..3d6eb487d 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -121,12 +121,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(unblockDomain(domain));
   },
 
-  onAddToList(account){
+  onAddToList (account) {
     dispatch(openModal('LIST_ADDER', {
       accountId: account.get('id'),
     }));
   },
 
+  onChangeLanguages (account) {
+    dispatch(openModal('SUBSCRIBED_LANGUAGES', {
+      accountId: account.get('id'),
+    }));
+  },
+
 });
 
 export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/mastodon/features/subscribed_languages_modal/index.js b/app/javascript/mastodon/features/subscribed_languages_modal/index.js
new file mode 100644
index 000000000..6a1bb2c47
--- /dev/null
+++ b/app/javascript/mastodon/features/subscribed_languages_modal/index.js
@@ -0,0 +1,121 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { is, List as ImmutableList, Set as ImmutableSet } from 'immutable';
+import { languages as preloadedLanguages } from 'mastodon/initial_state';
+import Option from 'mastodon/features/report/components/option';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import IconButton from 'mastodon/components/icon_button';
+import Button from 'mastodon/components/button';
+import { followAccount } from 'mastodon/actions/accounts';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+const getAccountLanguages = createSelector([
+  (state, accountId) => state.getIn(['timelines', `account:${accountId}`, 'items'], ImmutableList()),
+  state => state.get('statuses'),
+], (statusIds, statuses) =>
+  new ImmutableSet(statusIds.map(statusId => statuses.get(statusId)).filter(status => !status.get('reblog')).map(status => status.get('language'))));
+
+const mapStateToProps = (state, { accountId }) => ({
+  acct: state.getIn(['accounts', accountId, 'acct']),
+  availableLanguages: getAccountLanguages(state, accountId),
+  selectedLanguages: ImmutableSet(state.getIn(['relationships', accountId, 'languages']) || ImmutableList()),
+});
+
+const mapDispatchToProps = (dispatch, { accountId }) => ({
+
+  onSubmit (languages) {
+    dispatch(followAccount(accountId, { languages }));
+  },
+
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class SubscribedLanguagesModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    accountId: PropTypes.string.isRequired,
+    acct: PropTypes.string.isRequired,
+    availableLanguages: ImmutablePropTypes.setOf(PropTypes.string),
+    selectedLanguages: ImmutablePropTypes.setOf(PropTypes.string),
+    onClose: PropTypes.func.isRequired,
+    languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
+    intl: PropTypes.object.isRequired,
+    submit: PropTypes.func.isRequired,
+  };
+
+  static defaultProps = {
+    languages: preloadedLanguages,
+  };
+
+  state = {
+    selectedLanguages: this.props.selectedLanguages,
+  };
+
+  handleLanguageToggle = (value, checked) => {
+    const { selectedLanguages } = this.state;
+
+    if (checked) {
+      this.setState({ selectedLanguages: selectedLanguages.add(value) });
+    } else {
+      this.setState({ selectedLanguages: selectedLanguages.delete(value) });
+    }
+  };
+
+  handleSubmit = () => {
+    this.props.onSubmit(this.state.selectedLanguages.toArray());
+    this.props.onClose();
+  }
+
+  renderItem (value) {
+    const language = this.props.languages.find(language => language[0] === value);
+    const checked = this.state.selectedLanguages.includes(value);
+
+    return (
+      <Option
+        key={value}
+        name='languages'
+        value={value}
+        label={language[1]}
+        checked={checked}
+        onToggle={this.handleLanguageToggle}
+        multiple
+      />
+    );
+  }
+
+  render () {
+    const { acct, availableLanguages, selectedLanguages, intl, onClose } = this.props;
+
+    return (
+      <div className='modal-root__modal report-dialog-modal'>
+        <div className='report-modal__target'>
+          <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
+          <FormattedMessage id='subscribed_languages.target' defaultMessage='Change subscribed languages for {target}' values={{ target: <strong>{acct}</strong> }} />
+        </div>
+
+        <div className='report-dialog-modal__container'>
+          <p className='report-dialog-modal__lead'><FormattedMessage id='subscribed_languages.lead' defaultMessage='Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.' /></p>
+
+          <div>
+            {availableLanguages.union(selectedLanguages).map(value => this.renderItem(value))}
+          </div>
+
+          <div className='flex-spacer' />
+
+          <div className='report-dialog-modal__actions'>
+            <Button disabled={is(this.state.selectedLanguages, this.props.selectedLanguages)} onClick={this.handleSubmit}><FormattedMessage id='subscribed_languages.save' defaultMessage='Save changes' /></Button>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index b2c30e079..dfa89f2ce 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -11,6 +11,7 @@ import VideoModal from './video_modal';
 import BoostModal from './boost_modal';
 import AudioModal from './audio_modal';
 import ConfirmationModal from './confirmation_modal';
+import SubscribedLanguagesModal from 'mastodon/features/subscribed_languages_modal';
 import FocalPointModal from './focal_point_modal';
 import {
   MuteModal,
@@ -39,6 +40,7 @@ const MODAL_COMPONENTS = {
   'LIST_ADDER': ListAdder,
   'COMPARE_HISTORY': CompareHistoryModal,
   'FILTER': FilterModal,
+  'SUBSCRIBED_LANGUAGES': () => Promise.resolve({ default: SubscribedLanguagesModal }),
 };
 
 export default class ModalRoot extends React.PureComponent {
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 13ef56922..4c208c3cb 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -1031,6 +1031,10 @@
         "id": "status.admin_account"
       },
       {
+        "defaultMessage": "Change subscribed languages",
+        "id": "account.languages"
+      },
+      {
         "defaultMessage": "Follows you",
         "id": "account.follows_you"
       },
@@ -3353,6 +3357,27 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Close",
+        "id": "lightbox.close"
+      },
+      {
+        "defaultMessage": "Change subscribed languages for {target}",
+        "id": "subscribed_languages.target"
+      },
+      {
+        "defaultMessage": "Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.",
+        "id": "subscribed_languages.lead"
+      },
+      {
+        "defaultMessage": "Save changes",
+        "id": "subscribed_languages.save"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/subscribed_languages_modal/index.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Are you sure you want to block {name}?",
         "id": "confirmations.block.message"
       },
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 521bc4455..4f515b321 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -24,6 +24,7 @@
   "account.follows_you": "Follows you",
   "account.hide_reblogs": "Hide boosts from @{name}",
   "account.joined": "Joined {date}",
+  "account.languages": "Change subscribed languages",
   "account.link_verified_on": "Ownership of this link was checked on {date}",
   "account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
   "account.media": "Media",
@@ -522,6 +523,9 @@
   "status.uncached_media_warning": "Not available",
   "status.unmute_conversation": "Unmute conversation",
   "status.unpin": "Unpin from profile",
+  "subscribed_languages.lead": "Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.",
+  "subscribed_languages.save": "Save changes",
+  "subscribed_languages.target": "Change subscribed languages for {target}",
   "suggestions.dismiss": "Dismiss suggestion",
   "suggestions.header": "You might be interested in…",
   "tabs_bar.federated_timeline": "Federated",
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index ccff2667b..f2d204a64 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -354,6 +354,7 @@ class FeedManager
   def filter_from_home?(status, receiver_id, crutches)
     return false if receiver_id == status.account_id
     return true  if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
+    return true  if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language)
 
     check_for_blocks = crutches[:active_mentions][status.id] || []
     check_for_blocks.concat([status.account_id])
@@ -542,6 +543,7 @@ class FeedManager
     end
 
     crutches[:following]       = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:in_reply_to_account_id).compact).pluck(:target_account_id).index_with(true)
+    crutches[:languages]       = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:account_id)).pluck(:target_account_id, :languages).to_h
     crutches[:hiding_reblogs]  = Follow.where(account_id: receiver_id, target_account_id: statuses.map { |s| s.account_id if s.reblog? }.compact, show_reblogs: false).pluck(:target_account_id).index_with(true)
     crutches[:blocking]        = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).index_with(true)
     crutches[:muting]          = Mute.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).index_with(true)
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 9b358d338..15c49f2fe 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -9,6 +9,7 @@ module AccountInteractions
         mapping[follow.target_account_id] = {
           reblogs: follow.show_reblogs?,
           notify: follow.notify?,
+          languages: follow.languages,
         }
       end
     end
@@ -38,6 +39,7 @@ module AccountInteractions
         mapping[follow_request.target_account_id] = {
           reblogs: follow_request.show_reblogs?,
           notify: follow_request.notify?,
+          languages: follow_request.languages,
         }
       end
     end
@@ -100,12 +102,13 @@ module AccountInteractions
     has_many :announcement_mutes, dependent: :destroy
   end
 
-  def follow!(other_account, reblogs: nil, notify: nil, uri: nil, rate_limit: false, bypass_limit: false)
-    rel = active_relationships.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, uri: uri, rate_limit: rate_limit, bypass_follow_limit: bypass_limit)
+  def follow!(other_account, reblogs: nil, notify: nil, languages: nil, uri: nil, rate_limit: false, bypass_limit: false)
+    rel = active_relationships.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, languages: languages, uri: uri, rate_limit: rate_limit, bypass_follow_limit: bypass_limit)
                               .find_or_create_by!(target_account: other_account)
 
-    rel.show_reblogs = reblogs unless reblogs.nil?
-    rel.notify       = notify  unless notify.nil?
+    rel.show_reblogs = reblogs   unless reblogs.nil?
+    rel.notify       = notify    unless notify.nil?
+    rel.languages    = languages unless languages.nil?
 
     rel.save! if rel.changed?
 
@@ -114,12 +117,13 @@ module AccountInteractions
     rel
   end
 
-  def request_follow!(other_account, reblogs: nil, notify: nil, uri: nil, rate_limit: false, bypass_limit: false)
-    rel = follow_requests.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, uri: uri, rate_limit: rate_limit, bypass_follow_limit: bypass_limit)
+  def request_follow!(other_account, reblogs: nil, notify: nil, languages: nil, uri: nil, rate_limit: false, bypass_limit: false)
+    rel = follow_requests.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, uri: uri, languages: languages, rate_limit: rate_limit, bypass_follow_limit: bypass_limit)
                          .find_or_create_by!(target_account: other_account)
 
-    rel.show_reblogs = reblogs unless reblogs.nil?
-    rel.notify       = notify  unless notify.nil?
+    rel.show_reblogs = reblogs   unless reblogs.nil?
+    rel.notify       = notify    unless notify.nil?
+    rel.languages    = languages unless languages.nil?
 
     rel.save! if rel.changed?
 
@@ -288,8 +292,7 @@ module AccountInteractions
 
   private
 
-  def remove_potential_friendship(other_account, mutual = false)
+  def remove_potential_friendship(other_account)
     PotentialFriendshipTracker.remove(id, other_account.id)
-    PotentialFriendshipTracker.remove(other_account.id, id) if mutual
   end
 end
diff --git a/app/models/export.rb b/app/models/export.rb
index 5216eed5e..2457dcc15 100644
--- a/app/models/export.rb
+++ b/app/models/export.rb
@@ -30,9 +30,9 @@ class Export
   end
 
   def to_following_accounts_csv
-    CSV.generate(headers: ['Account address', 'Show boosts'], write_headers: true) do |csv|
+    CSV.generate(headers: ['Account address', 'Show boosts', 'Notify on new posts', 'Languages'], write_headers: true) do |csv|
       account.active_relationships.includes(:target_account).reorder(id: :desc).each do |follow|
-        csv << [acct(follow.target_account), follow.show_reblogs]
+        csv << [acct(follow.target_account), follow.show_reblogs, follow.notify, follow.languages&.join(', ')]
       end
     end
   end
diff --git a/app/models/follow.rb b/app/models/follow.rb
index a5e3fe809..e5cecbbc1 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -11,6 +11,7 @@
 #  show_reblogs      :boolean          default(TRUE), not null
 #  uri               :string
 #  notify            :boolean          default(FALSE), not null
+#  languages         :string           is an Array
 #
 
 class Follow < ApplicationRecord
@@ -27,6 +28,7 @@ class Follow < ApplicationRecord
   has_one :notification, as: :activity, dependent: :destroy
 
   validates :account_id, uniqueness: { scope: :target_account_id }
+  validates :languages, language: true
 
   scope :recent, -> { reorder(id: :desc) }
 
@@ -35,7 +37,7 @@ class Follow < ApplicationRecord
   end
 
   def revoke_request!
-    FollowRequest.create!(account: account, target_account: target_account, show_reblogs: show_reblogs, notify: notify, uri: uri)
+    FollowRequest.create!(account: account, target_account: target_account, show_reblogs: show_reblogs, notify: notify, languages: languages, uri: uri)
     destroy!
   end
 
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index 0b6f7629a..9034250c0 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -11,6 +11,7 @@
 #  show_reblogs      :boolean          default(TRUE), not null
 #  uri               :string
 #  notify            :boolean          default(FALSE), not null
+#  languages         :string           is an Array
 #
 
 class FollowRequest < ApplicationRecord
@@ -27,9 +28,10 @@ class FollowRequest < ApplicationRecord
   has_one :notification, as: :activity, dependent: :destroy
 
   validates :account_id, uniqueness: { scope: :target_account_id }
+  validates :languages, language: true
 
   def authorize!
-    account.follow!(target_account, reblogs: show_reblogs, notify: notify, uri: uri, bypass_limit: true)
+    account.follow!(target_account, reblogs: show_reblogs, notify: notify, languages: languages, uri: uri, bypass_limit: true)
     MergeWorker.perform_async(target_account.id, account.id) if account.local?
     destroy!
   end
diff --git a/app/serializers/rest/relationship_serializer.rb b/app/serializers/rest/relationship_serializer.rb
index afd4cddf9..31fc60eb2 100644
--- a/app/serializers/rest/relationship_serializer.rb
+++ b/app/serializers/rest/relationship_serializer.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class REST::RelationshipSerializer < ActiveModel::Serializer
-  attributes :id, :following, :showing_reblogs, :notifying, :followed_by,
+  attributes :id, :following, :showing_reblogs, :notifying, :languages, :followed_by,
              :blocking, :blocked_by, :muting, :muting_notifications, :requested,
              :domain_blocking, :endorsed, :note
 
@@ -25,6 +25,11 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
       false
   end
 
+  def languages
+    (instance_options[:relationships].following[object.id] || {})[:languages] ||
+      (instance_options[:relationships].requested[object.id] || {})[:languages]
+  end
+
   def followed_by
     instance_options[:relationships].followed_by[object.id] || false
   end
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index ed28e1371..feea40e3c 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -11,6 +11,7 @@ class FollowService < BaseService
   # @param [Hash] options
   # @option [Boolean] :reblogs Whether or not to show reblogs, defaults to true
   # @option [Boolean] :notify Whether to create notifications about new posts, defaults to false
+  # @option [Array<String>] :languages Which languages to allow on the home feed from this account, defaults to all
   # @option [Boolean] :bypass_locked
   # @option [Boolean] :bypass_limit Allow following past the total follow number
   # @option [Boolean] :with_rate_limit
@@ -57,15 +58,15 @@ class FollowService < BaseService
   end
 
   def change_follow_options!
-    @source_account.follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify])
+    @source_account.follow!(@target_account, **follow_options)
   end
 
   def change_follow_request_options!
-    @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify])
+    @source_account.request_follow!(@target_account, **follow_options)
   end
 
   def request_follow!
-    follow_request = @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit])
+    follow_request = @source_account.request_follow!(@target_account, **follow_options.merge(rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit]))
 
     if @target_account.local?
       LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, 'follow_request')
@@ -77,7 +78,7 @@ class FollowService < BaseService
   end
 
   def direct_follow!
-    follow = @source_account.follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit])
+    follow = @source_account.follow!(@target_account, **follow_options.merge(rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit]))
 
     LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name, 'follow')
     MergeWorker.perform_async(@target_account.id, @source_account.id)
@@ -88,4 +89,8 @@ class FollowService < BaseService
   def build_json(follow_request)
     Oj.dump(serialize_payload(follow_request, ActivityPub::FollowSerializer))
   end
+
+  def follow_options
+    @options.slice(:reblogs, :notify, :languages)
+  end
 end
diff --git a/app/services/import_service.rb b/app/services/import_service.rb
index 8e6640b9d..676c37bde 100644
--- a/app/services/import_service.rb
+++ b/app/services/import_service.rb
@@ -27,7 +27,7 @@ class ImportService < BaseService
 
   def import_follows!
     parse_import_data!(['Account address'])
-    import_relationships!('follow', 'unfollow', @account.following, ROWS_PROCESSING_LIMIT, reblogs: { header: 'Show boosts', default: true })
+    import_relationships!('follow', 'unfollow', @account.following, ROWS_PROCESSING_LIMIT, reblogs: { header: 'Show boosts', default: true }, notify: { header: 'Notify on new posts', default: false }, languages: { header: 'Languages', default: nil })
   end
 
   def import_blocks!
diff --git a/app/validators/language_validator.rb b/app/validators/language_validator.rb
new file mode 100644
index 000000000..b723e1a40
--- /dev/null
+++ b/app/validators/language_validator.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class LanguageValidator < ActiveModel::EachValidator
+  include LanguagesHelper
+
+  def validate_each(record, attribute, value)
+    record.errors.add(attribute, :invalid) unless valid?(value)
+  end
+
+  private
+
+  def valid?(str)
+    if str.nil?
+      true
+    elsif str.is_a?(Array)
+      str.all? { |x| valid_locale?(x) }
+    else
+      valid_locale?(str)
+    end
+  end
+end
diff --git a/app/workers/refollow_worker.rb b/app/workers/refollow_worker.rb
index 319b00109..4b712d3aa 100644
--- a/app/workers/refollow_worker.rb
+++ b/app/workers/refollow_worker.rb
@@ -10,8 +10,9 @@ class RefollowWorker
     return unless target_account.activitypub?
 
     target_account.passive_relationships.where(account: Account.where(domain: nil)).includes(:account).reorder(nil).find_each do |follow|
-      reblogs = follow.show_reblogs?
-      notify  = follow.notify?
+      reblogs   = follow.show_reblogs?
+      notify    = follow.notify?
+      languages = follow.languages
 
       # Locally unfollow remote account
       follower = follow.account
@@ -19,7 +20,7 @@ class RefollowWorker
 
       # Schedule re-follow
       begin
-        FollowService.new.call(follower, target_account, reblogs: reblogs, notify: notify, bypass_limit: true)
+        FollowService.new.call(follower, target_account, reblogs: reblogs, notify: notify, languages: languages, bypass_limit: true)
       rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound, Mastodon::UnexpectedResponseError, HTTP::Error, OpenSSL::SSL::SSLError
         next
       end
diff --git a/app/workers/unfollow_follow_worker.rb b/app/workers/unfollow_follow_worker.rb
index 0bd5ff472..7203b4888 100644
--- a/app/workers/unfollow_follow_worker.rb
+++ b/app/workers/unfollow_follow_worker.rb
@@ -10,11 +10,12 @@ class UnfollowFollowWorker
     old_target_account = Account.find(old_target_account_id)
     new_target_account = Account.find(new_target_account_id)
 
-    follow  = follower_account.active_relationships.find_by(target_account: old_target_account)
-    reblogs = follow&.show_reblogs?
-    notify  = follow&.notify?
+    follow    = follower_account.active_relationships.find_by(target_account: old_target_account)
+    reblogs   = follow&.show_reblogs?
+    notify    = follow&.notify?
+    languages = follow&.languages
 
-    FollowService.new.call(follower_account, new_target_account, reblogs: reblogs, notify: notify, bypass_locked: bypass_locked, bypass_limit: true)
+    FollowService.new.call(follower_account, new_target_account, reblogs: reblogs, notify: notify, languages: languages, bypass_locked: bypass_locked, bypass_limit: true)
     UnfollowService.new.call(follower_account, old_target_account, skip_unmerge: true)
   rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
     true
diff --git a/db/migrate/20220829192633_add_languages_to_follows.rb b/db/migrate/20220829192633_add_languages_to_follows.rb
new file mode 100644
index 000000000..f6cf48880
--- /dev/null
+++ b/db/migrate/20220829192633_add_languages_to_follows.rb
@@ -0,0 +1,5 @@
+class AddLanguagesToFollows < ActiveRecord::Migration[6.1]
+  def change
+    add_column :follows, :languages, :string, array: true
+  end
+end
diff --git a/db/migrate/20220829192658_add_languages_to_follow_requests.rb b/db/migrate/20220829192658_add_languages_to_follow_requests.rb
new file mode 100644
index 000000000..f98fabb22
--- /dev/null
+++ b/db/migrate/20220829192658_add_languages_to_follow_requests.rb
@@ -0,0 +1,5 @@
+class AddLanguagesToFollowRequests < ActiveRecord::Migration[6.1]
+  def change
+    add_column :follow_requests, :languages, :string, array: true
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index db22f538a..1a98b22db 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.define(version: 2022_08_27_195229) do
+ActiveRecord::Schema.define(version: 2022_08_29_192658) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -461,6 +461,7 @@ ActiveRecord::Schema.define(version: 2022_08_27_195229) do
     t.boolean "show_reblogs", default: true, null: false
     t.string "uri"
     t.boolean "notify", default: false, null: false
+    t.string "languages", array: true
     t.index ["account_id", "target_account_id"], name: "index_follow_requests_on_account_id_and_target_account_id", unique: true
   end
 
@@ -472,6 +473,7 @@ ActiveRecord::Schema.define(version: 2022_08_27_195229) do
     t.boolean "show_reblogs", default: true, null: false
     t.string "uri"
     t.boolean "notify", default: false, null: false
+    t.string "languages", array: true
     t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true
     t.index ["target_account_id"], name: "index_follows_on_target_account_id"
   end
diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb
index 5d5c245c5..d6bbcefd7 100644
--- a/spec/controllers/api/v1/accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts_controller_spec.rb
@@ -145,6 +145,17 @@ RSpec.describe Api::V1::AccountsController, type: :controller do
         expect(json[:showing_reblogs]).to be false
         expect(json[:notifying]).to be true
       end
+
+      it 'changes languages option' do
+        post :follow, params: { id: other_account.id, languages: %w(en es) }
+
+        json = body_as_json
+
+        expect(json[:following]).to be true
+        expect(json[:showing_reblogs]).to be false
+        expect(json[:notifying]).to be false
+        expect(json[:languages]).to match_array %w(en es)
+      end
     end
   end
 
diff --git a/spec/controllers/settings/exports/following_accounts_controller_spec.rb b/spec/controllers/settings/exports/following_accounts_controller_spec.rb
index 78858e772..bfe010555 100644
--- a/spec/controllers/settings/exports/following_accounts_controller_spec.rb
+++ b/spec/controllers/settings/exports/following_accounts_controller_spec.rb
@@ -11,7 +11,7 @@ describe Settings::Exports::FollowingAccountsController do
       sign_in user, scope: :user
       get :index, format: :csv
 
-      expect(response.body).to eq "Account address,Show boosts\nusername@domain,true\n"
+      expect(response.body).to eq "Account address,Show boosts,Notify on new posts,Languages\nusername@domain,true,false,\n"
     end
   end
 end
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index 48c57b86e..0f3b05e5a 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -127,6 +127,18 @@ RSpec.describe FeedManager do
         reblog = Fabricate(:status, reblog: status, account: jeff)
         expect(FeedManager.instance.filter?(:home, reblog, alice)).to be true
       end
+
+      it 'returns true for German post when follow is set to English only' do
+        alice.follow!(bob, languages: %w(en))
+        status = Fabricate(:status, text: 'Hallo Welt', account: bob, language: 'de')
+        expect(FeedManager.instance.filter?(:home, status, alice)).to be true
+      end
+
+      it 'returns false for German post when follow is set to German' do
+        alice.follow!(bob, languages: %w(de))
+        status = Fabricate(:status, text: 'Hallo Welt', account: bob, language: 'de')
+        expect(FeedManager.instance.filter?(:home, status, alice)).to be false
+      end
     end
 
     context 'for mentions feed' do
diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb
index 0369aff10..1d1898ab0 100644
--- a/spec/models/concerns/account_interactions_spec.rb
+++ b/spec/models/concerns/account_interactions_spec.rb
@@ -14,7 +14,7 @@ describe AccountInteractions do
     context 'account with Follow' do
       it 'returns { target_account_id => true }' do
         Fabricate(:follow, account: account, target_account: target_account)
-        is_expected.to eq(target_account_id => { reblogs: true, notify: false })
+        is_expected.to eq(target_account_id => { reblogs: true, notify: false, languages: nil })
       end
     end
 
diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb
index 4e6b824bb..135d7a36b 100644
--- a/spec/models/export_spec.rb
+++ b/spec/models/export_spec.rb
@@ -35,8 +35,8 @@ describe Export do
       results = export.strip.split("\n")
 
       expect(results.size).to eq 3
-      expect(results.first).to eq 'Account address,Show boosts'
-      expect(results.second).to eq 'one@local.host,true'
+      expect(results.first).to eq 'Account address,Show boosts,Notify on new posts,Languages'
+      expect(results.second).to eq 'one@local.host,true,false,'
     end
   end
 
diff --git a/spec/models/follow_request_spec.rb b/spec/models/follow_request_spec.rb
index 36ce8ee60..901eabc9d 100644
--- a/spec/models/follow_request_spec.rb
+++ b/spec/models/follow_request_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe FollowRequest, type: :model do
     let(:target_account) { Fabricate(:account) }
 
     it 'calls Account#follow!, MergeWorker.perform_async, and #destroy!' do
-      expect(account).to        receive(:follow!).with(target_account, reblogs: true, notify: false, uri: follow_request.uri, bypass_limit: true)
+      expect(account).to        receive(:follow!).with(target_account, reblogs: true, notify: false, uri: follow_request.uri, languages: nil, bypass_limit: true)
       expect(MergeWorker).to    receive(:perform_async).with(target_account.id, account.id)
       expect(follow_request).to receive(:destroy!)
       follow_request.authorize!
diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb
index 02bc87c58..88346ec54 100644
--- a/spec/services/follow_service_spec.rb
+++ b/spec/services/follow_service_spec.rb
@@ -121,6 +121,19 @@ RSpec.describe FollowService, type: :service do
         expect(sender.muting_reblogs?(bob)).to be false
       end
     end
+
+    describe 'already followed account, changing languages' do
+      let(:bob) { Fabricate(:account, username: 'bob') }
+
+      before do
+        sender.follow!(bob)
+        subject.call(sender, bob, languages: %w(en es))
+      end
+
+      it 'changes languages' do
+        expect(Follow.find_by(account: sender, target_account: bob)&.languages).to match_array %w(en es)
+      end
+    end
   end
 
   context 'remote ActivityPub account' do
diff --git a/spec/workers/refollow_worker_spec.rb b/spec/workers/refollow_worker_spec.rb
index df6731b64..d9c2293b6 100644
--- a/spec/workers/refollow_worker_spec.rb
+++ b/spec/workers/refollow_worker_spec.rb
@@ -23,8 +23,8 @@ describe RefollowWorker do
       result = subject.perform(account.id)
 
       expect(result).to be_nil
-      expect(service).to have_received(:call).with(alice, account, reblogs: true, notify: false, bypass_limit: true)
-      expect(service).to have_received(:call).with(bob, account, reblogs: false, notify: false, bypass_limit: true)
+      expect(service).to have_received(:call).with(alice, account, reblogs: true, notify: false, languages: nil, bypass_limit: true)
+      expect(service).to have_received(:call).with(bob, account, reblogs: false, notify: false, languages: nil, bypass_limit: true)
     end
   end
 end