From bacb6749217767de83120ad1dea6a59f69ee66d9 Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Mon, 5 Jun 2023 00:37:02 -0700 Subject: [PATCH] Add exclusive lists (#22048) Co-authored-by: Liam Cooke Co-authored-by: John Holdun Co-authored-by: Effy Elden Co-authored-by: Lina Reyne Co-authored-by: Lina <20880695+necropolina@users.noreply.github.com> Co-authored-by: Claire --- app/controllers/api/v1/lists_controller.rb | 2 +- app/javascript/mastodon/actions/lists.js | 4 +- .../mastodon/features/list_timeline/index.jsx | 18 ++++++++- app/javascript/mastodon/locales/en.json | 1 + .../mastodon/reducers/list_editor.js | 2 + app/lib/feed_manager.rb | 26 +++++++------ app/models/list.rb | 1 + app/serializers/rest/list_serializer.rb | 2 +- .../20230605085710_add_exclusive_to_lists.rb | 7 ++++ db/schema.rb | 3 +- spec/lib/feed_manager_spec.rb | 37 +++++++++++++++++++ 11 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 db/migrate/20230605085710_add_exclusive_to_lists.rb diff --git a/app/controllers/api/v1/lists_controller.rb b/app/controllers/api/v1/lists_controller.rb index 843ca2ec2b..4bbbed2673 100644 --- a/app/controllers/api/v1/lists_controller.rb +++ b/app/controllers/api/v1/lists_controller.rb @@ -42,6 +42,6 @@ class Api::V1::ListsController < Api::BaseController end def list_params - params.permit(:title, :replies_policy) + params.permit(:title, :replies_policy, :exclusive) end end diff --git a/app/javascript/mastodon/actions/lists.js b/app/javascript/mastodon/actions/lists.js index 2faa54b955..b0789cd426 100644 --- a/app/javascript/mastodon/actions/lists.js +++ b/app/javascript/mastodon/actions/lists.js @@ -151,10 +151,10 @@ export const createListFail = error => ({ error, }); -export const updateList = (id, title, shouldReset, replies_policy) => (dispatch, getState) => { +export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => { dispatch(updateListRequest(id)); - api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy }).then(({ data }) => { + api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => { dispatch(updateListSuccess(data)); if (shouldReset) { diff --git a/app/javascript/mastodon/features/list_timeline/index.jsx b/app/javascript/mastodon/features/list_timeline/index.jsx index f41e8e6f23..f9f3a7c315 100644 --- a/app/javascript/mastodon/features/list_timeline/index.jsx +++ b/app/javascript/mastodon/features/list_timeline/index.jsx @@ -8,6 +8,8 @@ import { Helmet } from 'react-helmet'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; +import Toggle from 'react-toggle'; + import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; import { fetchList, deleteList, updateList } from 'mastodon/actions/lists'; import { openModal } from 'mastodon/actions/modal'; @@ -145,7 +147,13 @@ class ListTimeline extends PureComponent { handleRepliesPolicyChange = ({ target }) => { const { dispatch } = this.props; const { id } = this.props.params; - dispatch(updateList(id, undefined, false, target.value)); + dispatch(updateList(id, undefined, false, undefined, target.value)); + }; + + onExclusiveToggle = ({ target }) => { + const { dispatch } = this.props; + const { id } = this.props.params; + dispatch(updateList(id, undefined, false, target.checked, undefined)); }; render () { @@ -154,6 +162,7 @@ class ListTimeline extends PureComponent { const pinned = !!columnId; const title = list ? list.get('title') : id; const replies_policy = list ? list.get('replies_policy') : undefined; + const isExclusive = list ? list.get('exclusive') : undefined; if (typeof list === 'undefined') { return ( @@ -191,6 +200,13 @@ class ListTimeline extends PureComponent { +
+ + +
+ { replies_policy !== undefined && (
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index f6d6daa3e5..09282de7c8 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -356,6 +356,7 @@ "lists.delete": "Delete list", "lists.edit": "Edit list", "lists.edit.submit": "Change title", + "lists.exclusive": "Hide these posts from home", "lists.new.create": "Add list", "lists.new.title_placeholder": "New list title", "lists.replies_policy.followed": "Any followed user", diff --git a/app/javascript/mastodon/reducers/list_editor.js b/app/javascript/mastodon/reducers/list_editor.js index ceceb27c7a..d3fd62adec 100644 --- a/app/javascript/mastodon/reducers/list_editor.js +++ b/app/javascript/mastodon/reducers/list_editor.js @@ -25,6 +25,7 @@ const initialState = ImmutableMap({ isSubmitting: false, isChanged: false, title: '', + isExclusive: false, accounts: ImmutableMap({ items: ImmutableList(), @@ -46,6 +47,7 @@ export default function listEditorReducer(state = initialState, action) { return state.withMutations(map => { map.set('listId', action.list.get('id')); map.set('title', action.list.get('title')); + map.set('isExclusive', action.list.get('is_exclusive')); map.set('isSubmitting', false); }); case LIST_EDITOR_TITLE_CHANGE: diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 643e6828d2..7423d2d092 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -40,9 +40,9 @@ class FeedManager def filter?(timeline_type, status, receiver) case timeline_type when :home - filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status])) + filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status]), :home) when :list - filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status])) + filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]), :list) when :mentions filter_from_mentions?(status, receiver.id) when :tags @@ -351,10 +351,11 @@ class FeedManager # @param [Integer] receiver_id # @param [Hash] crutches # @return [Boolean] - def filter_from_home?(status, receiver_id, crutches) + def filter_from_home?(status, receiver_id, crutches, timeline_type = :home) 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) + return true if timeline_type != :list && crutches[:exclusive_list_users][status.account_id].present? + 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.push(status.account_id) @@ -543,13 +544,16 @@ class FeedManager arr end - crutches[:following] = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map(&:in_reply_to_account_id)).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.filter_map { |s| s.account_id if s.reblog? }, 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) - crutches[:domain_blocking] = AccountDomainBlock.where(account_id: receiver_id, domain: statuses.flat_map { |s| [s.account.domain, s.reblog&.account&.domain] }.compact).pluck(:domain).index_with(true) - crutches[:blocked_by] = Block.where(target_account_id: receiver_id, account_id: statuses.map { |s| [s.account_id, s.reblog&.account_id] }.flatten.compact).pluck(:account_id).index_with(true) + lists = List.where(account_id: receiver_id, exclusive: true) + + crutches[:following] = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map(&:in_reply_to_account_id)).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.filter_map { |s| s.account_id if s.reblog? }, 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) + crutches[:domain_blocking] = AccountDomainBlock.where(account_id: receiver_id, domain: statuses.flat_map { |s| [s.account.domain, s.reblog&.account&.domain] }.compact).pluck(:domain).index_with(true) + crutches[:blocked_by] = Block.where(target_account_id: receiver_id, account_id: statuses.map { |s| [s.account_id, s.reblog&.account_id] }.flatten.compact).pluck(:account_id).index_with(true) + crutches[:exclusive_list_users] = ListAccount.where(list: lists, account_id: statuses.map(&:account_id)).pluck(:account_id).index_with(true) crutches end diff --git a/app/models/list.rb b/app/models/list.rb index bd1bdbd24d..7dc96f01b3 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -10,6 +10,7 @@ # created_at :datetime not null # updated_at :datetime not null # replies_policy :integer default("list"), not null +# exclusive :boolean default(FALSE) # class List < ApplicationRecord diff --git a/app/serializers/rest/list_serializer.rb b/app/serializers/rest/list_serializer.rb index 3e87f71196..6a1b6ea3eb 100644 --- a/app/serializers/rest/list_serializer.rb +++ b/app/serializers/rest/list_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class REST::ListSerializer < ActiveModel::Serializer - attributes :id, :title, :replies_policy + attributes :id, :title, :replies_policy, :exclusive def id object.id.to_s diff --git a/db/migrate/20230605085710_add_exclusive_to_lists.rb b/db/migrate/20230605085710_add_exclusive_to_lists.rb new file mode 100644 index 0000000000..cc21a3e315 --- /dev/null +++ b/db/migrate/20230605085710_add_exclusive_to_lists.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddExclusiveToLists < ActiveRecord::Migration[6.1] + def change + add_column :lists, :exclusive, :boolean, null: false, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 98fa5d6004..35fbb8d2ef 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: 2023_05_31_154811) do +ActiveRecord::Schema.define(version: 2023_06_05_085710) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -567,6 +567,7 @@ ActiveRecord::Schema.define(version: 2023_05_31_154811) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "replies_policy", default: 0, null: false + t.boolean "exclusive", default: false t.index ["account_id"], name: "index_lists_on_account_id" end diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index 79d1f5249e..31b53fd879 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -26,6 +26,7 @@ RSpec.describe FeedManager do let(:alice) { Fabricate(:account, username: 'alice') } let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } let(:jeff) { Fabricate(:account, username: 'jeff') } + let(:list) { Fabricate(:list, account: alice) } context 'with home feed' do it 'returns false for followee\'s status' do @@ -153,6 +154,42 @@ RSpec.describe FeedManager do status = Fabricate(:status, text: 'Hallo Welt', account: bob, language: 'de') expect(FeedManager.instance.filter?(:home, status, alice)).to be false end + + it 'returns true for post from followee on exclusive list' do + list.exclusive = true + alice.follow!(bob) + list.accounts << bob + allow(List).to receive(:where).and_return(list) + status = Fabricate(:status, text: 'I post a lot', account: bob) + expect(FeedManager.instance.filter?(:home, status, alice)).to be true + end + + it 'returns true for reblog from followee on exclusive list' do + list.exclusive = true + alice.follow!(jeff) + list.accounts << jeff + allow(List).to receive(:where).and_return(list) + status = Fabricate(:status, text: 'I post a lot', account: bob) + reblog = Fabricate(:status, reblog: status, account: jeff) + expect(FeedManager.instance.filter?(:home, reblog, alice)).to be true + end + + it 'returns false for post from followee on non-exclusive list' do + list.exclusive = false + alice.follow!(bob) + list.accounts << bob + status = Fabricate(:status, text: 'I post a lot', account: bob) + expect(FeedManager.instance.filter?(:home, status, alice)).to be false + end + + it 'returns false for reblog from followee on non-exclusive list' do + list.exclusive = false + alice.follow!(jeff) + list.accounts << jeff + status = Fabricate(:status, text: 'I post a lot', account: bob) + reblog = Fabricate(:status, reblog: status, account: jeff) + expect(FeedManager.instance.filter?(:home, reblog, alice)).to be false + end end context 'with mentions feed' do