~cytrogen/masto-fe

bacb6749217767de83120ad1dea6a59f69ee66d9 — Darius Kazemi 2 years ago 0daf78f
Add exclusive lists (#22048)

Co-authored-by: Liam Cooke <liam@liamcooke.com>
Co-authored-by: John Holdun <john@johnholdun.com>
Co-authored-by: Effy Elden <effy@effy.space>
Co-authored-by: Lina Reyne <git@lina.pizza>
Co-authored-by: Lina <20880695+necropolina@users.noreply.github.com>
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
M app/controllers/api/v1/lists_controller.rb => app/controllers/api/v1/lists_controller.rb +1 -1
@@ 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

M app/javascript/mastodon/actions/lists.js => app/javascript/mastodon/actions/lists.js +2 -2
@@ 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) {

M app/javascript/mastodon/features/list_timeline/index.jsx => app/javascript/mastodon/features/list_timeline/index.jsx +17 -1
@@ 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 {
            </button>
          </div>

          <div className='setting-toggle'>
            <Toggle id={`list-${id}-exclusive`} defaultChecked={isExclusive} onChange={this.onExclusiveToggle} />
            <label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
              <FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
            </label>
          </div>

          { replies_policy !== undefined && (
            <div role='group' aria-labelledby={`list-${id}-replies-policy`}>
              <span id={`list-${id}-replies-policy`} className='column-settings__section'>

M app/javascript/mastodon/locales/en.json => app/javascript/mastodon/locales/en.json +1 -0
@@ 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",

M app/javascript/mastodon/reducers/list_editor.js => app/javascript/mastodon/reducers/list_editor.js +2 -0
@@ 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:

M app/lib/feed_manager.rb => app/lib/feed_manager.rb +15 -11
@@ 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

M app/models/list.rb => app/models/list.rb +1 -0
@@ 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

M app/serializers/rest/list_serializer.rb => app/serializers/rest/list_serializer.rb +1 -1
@@ 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

A db/migrate/20230605085710_add_exclusive_to_lists.rb => db/migrate/20230605085710_add_exclusive_to_lists.rb +7 -0
@@ 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

M db/schema.rb => db/schema.rb +2 -1
@@ 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


M spec/lib/feed_manager_spec.rb => spec/lib/feed_manager_spec.rb +37 -0
@@ 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