From f20da7bf74ded5f19412f4ed299a246e1d86f42c Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 22 Aug 2023 06:46:17 +0900 Subject: [PATCH 1/8] Fix account own circle only when post --- app/services/post_status_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 4e90d1cc6b..6b7a75e111 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -95,7 +95,7 @@ class PostStatusService < BaseService return unless @options[:visibility] == 'circle' @circle = @options[:circle_id].present? && Circle.find(@options[:circle_id]) - raise ArgumentError if @circle.nil? + raise ArgumentError if @circle.nil? || @circle.account_id != @account.id end def process_sensitive_words From 45fb44353c9140e40689f70cc3606c73d2148d85 Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 22 Aug 2023 07:35:09 +0900 Subject: [PATCH 2/8] Add antenna tag editor --- .../v1/antennas/exclude_tags_controller.rb | 50 ++++++ .../api/v1/antennas/tags_controller.rb | 58 +++++++ app/javascript/mastodon/actions/antennas.js | 153 ++++++++++++++++++ .../features/antenna_setting/index.jsx | 59 ++++++- app/javascript/mastodon/reducers/antennas.js | 15 ++ app/models/antenna.rb | 1 + config/routes/api.rb | 2 + 7 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 app/controllers/api/v1/antennas/exclude_tags_controller.rb create mode 100644 app/controllers/api/v1/antennas/tags_controller.rb diff --git a/app/controllers/api/v1/antennas/exclude_tags_controller.rb b/app/controllers/api/v1/antennas/exclude_tags_controller.rb new file mode 100644 index 0000000000..bf9e087369 --- /dev/null +++ b/app/controllers/api/v1/antennas/exclude_tags_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class Api::V1::Antennas::ExcludeTagsController < Api::BaseController + before_action -> { doorkeeper_authorize! :write, :'write:lists' } + + before_action :require_user! + before_action :set_antenna + + def create + new_tags = @antenna.exclude_tags || [] + tags.map(&:id).each do |tag| + raise Mastodon::ValidationError, I18n.t('antennas.errors.duplicate_tag') if new_tags.include?(tag) + + new_tags << tag + end + + raise Mastodon::ValidationError, I18n.t('antennas.errors.limit.tags') if new_tags.size > Antenna::TAGS_PER_ANTENNA_LIMIT + + @antenna.update!(exclude_tags: new_tags) + + render_empty + end + + def destroy + new_tags = @antenna.exclude_tags || [] + new_tags -= exist_tags.pluck(:id) + + @antenna.update!(exclude_tags: new_tags) + + render_empty + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:antenna_id]) + end + + def tags + Tag.find_or_create_by_names(Array(resource_params[:tags])) + end + + def exist_tags + Tag.matching_name(Array(resource_params[:tags])) + end + + def resource_params + params.permit(tags: []) + end +end diff --git a/app/controllers/api/v1/antennas/tags_controller.rb b/app/controllers/api/v1/antennas/tags_controller.rb new file mode 100644 index 0000000000..fe0bb6b4eb --- /dev/null +++ b/app/controllers/api/v1/antennas/tags_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class Api::V1::Antennas::TagsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show] + + before_action :require_user! + before_action :set_antenna + + def show + @tags = load_tags + @exclude_tags = load_exclude_tags + render json: { tags: @tags, exclude_tags: @exclude_tags.pluck(:name) } + end + + def create + ApplicationRecord.transaction do + tags.each do |tag| + @antenna.antenna_tags.create!(tag: tag, exclude: false) + @antenna.update!(any_tags: false) if @antenna.any_tags + end + end + + render_empty + end + + def destroy + AntennaTag.where(antenna: @antenna, tag: exist_tags).destroy_all + @antenna.update!(any_tags: true) unless @antenna.antenna_tags.where(exclude: false).any? + render_empty + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:antenna_id]) + end + + def load_tags + @antenna.tags.pluck(:name) + end + + def load_exclude_tags + Tag.where(id: @antenna.exclude_tags || []) + end + + def tags + Tag.find_or_create_by_names(Array(resource_params[:tags])) + end + + def exist_tags + Tag.matching_name(Array(resource_params[:tags])) + end + + def resource_params + params.permit(tags: []) + end +end diff --git a/app/javascript/mastodon/actions/antennas.js b/app/javascript/mastodon/actions/antennas.js index 3e561cbacb..2b0d5d042a 100644 --- a/app/javascript/mastodon/actions/antennas.js +++ b/app/javascript/mastodon/actions/antennas.js @@ -95,6 +95,26 @@ export const ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_REQUEST = 'ANTENNA_EDITOR_REM export const ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_SUCCESS'; export const ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_FAIL = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_FAIL'; +export const ANTENNA_EDITOR_FETCH_TAGS_REQUEST = 'ANTENNA_EDITOR_FETCH_TAGS_REQUEST'; +export const ANTENNA_EDITOR_FETCH_TAGS_SUCCESS = 'ANTENNA_EDITOR_FETCH_TAGS_SUCCESS'; +export const ANTENNA_EDITOR_FETCH_TAGS_FAIL = 'ANTENNA_EDITOR_FETCH_TAGS_FAIL'; + +export const ANTENNA_EDITOR_ADD_TAG_REQUEST = 'ANTENNA_EDITOR_ADD_TAG_REQUEST'; +export const ANTENNA_EDITOR_ADD_TAG_SUCCESS = 'ANTENNA_EDITOR_ADD_TAG_SUCCESS'; +export const ANTENNA_EDITOR_ADD_TAG_FAIL = 'ANTENNA_EDITOR_ADD_TAG_FAIL'; + +export const ANTENNA_EDITOR_ADD_EXCLUDE_TAG_REQUEST = 'ANTENNA_EDITOR_ADD_EXCLUDE_TAG_REQUEST'; +export const ANTENNA_EDITOR_ADD_EXCLUDE_TAG_SUCCESS = 'ANTENNA_EDITOR_ADD_EXCLUDE_TAG_SUCCESS'; +export const ANTENNA_EDITOR_ADD_EXCLUDE_TAG_FAIL = 'ANTENNA_EDITOR_ADD_EXCLUDE_TAG_FAIL'; + +export const ANTENNA_EDITOR_REMOVE_TAG_REQUEST = 'ANTENNA_EDITOR_REMOVE_TAG_REQUEST'; +export const ANTENNA_EDITOR_REMOVE_TAG_SUCCESS = 'ANTENNA_EDITOR_REMOVE_TAG_SUCCESS'; +export const ANTENNA_EDITOR_REMOVE_TAG_FAIL = 'ANTENNA_EDITOR_REMOVE_TAG_FAIL'; + +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_REQUEST = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_REQUEST'; +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_SUCCESS = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_SUCCESS'; +export const ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_FAIL = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_FAIL'; + export const ANTENNA_ADDER_RESET = 'ANTENNA_ADDER_RESET'; export const ANTENNA_ADDER_SETUP = 'ANTENNA_ADDER_SETUP'; @@ -739,6 +759,139 @@ export const removeExcludeKeywordFromAntennaFail = (antennaId, keyword, error) = error, }); +export const fetchAntennaTags = antennaId => (dispatch, getState) => { + dispatch(fetchAntennaTagsRequest(antennaId)); + + api(getState).get(`/api/v1/antennas/${antennaId}/tags`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(fetchAntennaTagsSuccess(antennaId, data)); + }).catch(err => dispatch(fetchAntennaTagsFail(antennaId, err))); +}; + +export const fetchAntennaTagsRequest = id => ({ + type: ANTENNA_EDITOR_FETCH_TAGS_REQUEST, + id, +}); + +export const fetchAntennaTagsSuccess = (id, tags) => ({ + type: ANTENNA_EDITOR_FETCH_TAGS_SUCCESS, + id, + tags, +}); + +export const fetchAntennaTagsFail = (id, error) => ({ + type: ANTENNA_EDITOR_FETCH_TAGS_FAIL, + id, + error, +}); + +export const addTagToAntenna = (antennaId, tag) => (dispatch, getState) => { + dispatch(addTagToAntennaRequest(antennaId, tag)); + + api(getState).post(`/api/v1/antennas/${antennaId}/tags`, { tags: [tag] }) + .then(() => dispatch(addTagToAntennaSuccess(antennaId, tag))) + .catch(err => dispatch(addTagToAntennaFail(antennaId, tag, err))); +}; + +export const addTagToAntennaRequest = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_ADD_TAG_REQUEST, + antennaId, + tag, +}); + +export const addTagToAntennaSuccess = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_ADD_TAG_SUCCESS, + antennaId, + tag, +}); + +export const addTagToAntennaFail = (antennaId, tag, error) => ({ + type: ANTENNA_EDITOR_ADD_TAG_FAIL, + antennaId, + tag, + error, +}); + +export const removeTagFromAntenna = (antennaId, tag) => (dispatch, getState) => { + dispatch(removeTagFromAntennaRequest(antennaId, tag)); + + api(getState).delete(`/api/v1/antennas/${antennaId}/tags`, { params: { tags: [tag] } }) + .then(() => dispatch(removeTagFromAntennaSuccess(antennaId, tag))) + .catch(err => dispatch(removeTagFromAntennaFail(antennaId, tag, err))); +}; + +export const removeTagFromAntennaRequest = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_REMOVE_TAG_REQUEST, + antennaId, + tag, +}); + +export const removeTagFromAntennaSuccess = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_REMOVE_TAG_SUCCESS, + antennaId, + tag, +}); + +export const removeTagFromAntennaFail = (antennaId, tag, error) => ({ + type: ANTENNA_EDITOR_REMOVE_TAG_FAIL, + antennaId, + tag, + error, +}); + +export const addExcludeTagToAntenna = (antennaId, tag) => (dispatch, getState) => { + dispatch(addExcludeTagToAntennaRequest(antennaId, tag)); + + api(getState).post(`/api/v1/antennas/${antennaId}/exclude_tags`, { tags: [tag] }) + .then(() => dispatch(addExcludeTagToAntennaSuccess(antennaId, tag))) + .catch(err => dispatch(addExcludeTagToAntennaFail(antennaId, tag, err))); +}; + +export const addExcludeTagToAntennaRequest = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_TAG_REQUEST, + antennaId, + tag, +}); + +export const addExcludeTagToAntennaSuccess = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_TAG_SUCCESS, + antennaId, + tag, +}); + +export const addExcludeTagToAntennaFail = (antennaId, tag, error) => ({ + type: ANTENNA_EDITOR_ADD_EXCLUDE_TAG_FAIL, + antennaId, + tag, + error, +}); + +export const removeExcludeTagFromAntenna = (antennaId, tag) => (dispatch, getState) => { + dispatch(removeExcludeTagFromAntennaRequest(antennaId, tag)); + + api(getState).delete(`/api/v1/antennas/${antennaId}/exclude_tags`, { params: { tags: [tag] } }) + .then(() => dispatch(removeExcludeTagFromAntennaSuccess(antennaId, tag))) + .catch(err => dispatch(removeExcludeTagFromAntennaFail(antennaId, tag, err))); +}; + +export const removeExcludeTagFromAntennaRequest = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_REQUEST, + antennaId, + tag, +}); + +export const removeExcludeTagFromAntennaSuccess = (antennaId, tag) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_SUCCESS, + antennaId, + tag, +}); + +export const removeExcludeTagFromAntennaFail = (antennaId, tag, error) => ({ + type: ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_FAIL, + antennaId, + tag, + error, +}); + export const resetAntennaAdder = () => ({ type: ANTENNA_ADDER_RESET, }); diff --git a/app/javascript/mastodon/features/antenna_setting/index.jsx b/app/javascript/mastodon/features/antenna_setting/index.jsx index 7e0c236c83..83f21fd066 100644 --- a/app/javascript/mastodon/features/antenna_setting/index.jsx +++ b/app/javascript/mastodon/features/antenna_setting/index.jsx @@ -25,7 +25,12 @@ import { removeKeywordFromAntenna, addKeywordToAntenna, removeExcludeKeywordFromAntenna, - addExcludeKeywordToAntenna + addExcludeKeywordToAntenna, + fetchAntennaTags, + removeTagFromAntenna, + addTagToAntenna, + removeExcludeTagFromAntenna, + addExcludeTagToAntenna, } from 'mastodon/actions/antennas'; import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; import { fetchLists } from 'mastodon/actions/lists'; @@ -48,8 +53,10 @@ const messages = defineMessages({ placeholder: { id: 'antennas.select.placeholder', defaultMessage: 'Select list' }, addDomainLabel: { id: 'antennas.add_domain_placeholder', defaultMessage: 'New domain' }, addKeywordLabel: { id: 'antennas.add_keyword_placeholder', defaultMessage: 'New keyword' }, + addTagLabel: { id: 'antennas.add_tag_placeholder', defaultMessage: 'New tag' }, addDomainTitle: { id: 'antennas.add_domain', defaultMessage: 'Add domain' }, addKeywordTitle: { id: 'antennas.add_keyword', defaultMessage: 'Add keyword' }, + addTagTitle: { id: 'antennas.add_tag', defaultMessage: 'Add tag' }, accounts: { id: 'antennas.accounts', defaultMessage: '{count} accounts' }, domains: { id: 'antennas.domains', defaultMessage: '{count} domains' }, tags: { id: 'antennas.tags', defaultMessage: '{count} tags' }, @@ -62,6 +69,7 @@ const mapStateToProps = (state, props) => ({ lists: state.get('lists'), domains: state.getIn(['antennas', props.params.id, 'domains']) || ImmutableMap(), keywords: state.getIn(['antennas', props.params.id, 'keywords']) || ImmutableMap(), + tags: state.getIn(['antennas', props.params.id, 'tags']) || ImmutableMap(), }); class AntennaSetting extends PureComponent { @@ -79,6 +87,7 @@ class AntennaSetting extends PureComponent { lists: ImmutablePropTypes.map, domains: ImmutablePropTypes.map, keywords: ImmutablePropTypes.map, + tags: ImmutablePropTypes.map, intl: PropTypes.object.isRequired, }; @@ -87,6 +96,8 @@ class AntennaSetting extends PureComponent { excludeDomainName: '', keywordName: '', excludeKeywordName: '', + tagName: '', + excludeTagName: '', rangeRadioValue: null, contentRadioValue: null, }; @@ -118,6 +129,7 @@ class AntennaSetting extends PureComponent { dispatch(fetchAntenna(id)); dispatch(fetchAntennaDomains(id)); dispatch(fetchAntennaKeywords(id)); + dispatch(fetchAntennaTags(id)); dispatch(fetchLists()); } @@ -130,6 +142,7 @@ class AntennaSetting extends PureComponent { dispatch(fetchAntennaKeywords(id)); dispatch(fetchAntennaDomains(id)); dispatch(fetchAntennaKeywords(id)); + dispatch(fetchAntennaTags(id)); dispatch(fetchLists()); } } @@ -238,6 +251,15 @@ class AntennaSetting extends PureComponent { onKeywordRemove = (value) => this.props.dispatch(removeKeywordFromAntenna(this.props.params.id, value)); + onTagNameChanged = (value) => this.setState({ tagName: value }); + + onTagAdd = () => { + this.props.dispatch(addTagToAntenna(this.props.params.id, this.state.tagName)); + this.setState({ tagName: '' }); + }; + + onTagRemove = (value) => this.props.dispatch(removeTagFromAntenna(this.props.params.id, value)); + onExcludeDomainNameChanged = (value) => this.setState({ excludeDomainName: value }); onExcludeDomainAdd = () => { @@ -256,8 +278,17 @@ class AntennaSetting extends PureComponent { onExcludeKeywordRemove = (value) => this.props.dispatch(removeExcludeKeywordFromAntenna(this.props.params.id, value)); + onExcludeTagNameChanged = (value) => this.setState({ excludeTagName: value }); + + onExcludeTagAdd = () => { + this.props.dispatch(addExcludeTagToAntenna(this.props.params.id, this.state.excludeTagName)); + this.setState({ excludeTagName: '' }); + }; + + onExcludeTagRemove = (value) => this.props.dispatch(removeExcludeTagFromAntenna(this.props.params.id, value)); + render () { - const { columnId, multiColumn, antenna, lists, domains, keywords, intl } = this.props; + const { columnId, multiColumn, antenna, lists, domains, keywords, tags, intl } = this.props; const { id } = this.props.params; const pinned = !!columnId; const title = antenna ? antenna.get('title') : id; @@ -422,6 +453,19 @@ class AntennaSetting extends PureComponent { + {contentRadioValue.get('value') === 'tags' && ( + + )} + {contentRadioValue.get('value') === 'keywords' && ( +

+ )} diff --git a/app/javascript/mastodon/reducers/antennas.js b/app/javascript/mastodon/reducers/antennas.js index e6075dde90..7ef16f8dd2 100644 --- a/app/javascript/mastodon/reducers/antennas.js +++ b/app/javascript/mastodon/reducers/antennas.js @@ -19,6 +19,11 @@ import { ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_SUCCESS, ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_SUCCESS, ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS, + ANTENNA_EDITOR_ADD_TAG_SUCCESS, + ANTENNA_EDITOR_REMOVE_TAG_SUCCESS, + ANTENNA_EDITOR_ADD_EXCLUDE_TAG_SUCCESS, + ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_SUCCESS, + ANTENNA_EDITOR_FETCH_TAGS_SUCCESS, } from '../actions/antennas'; const initialState = ImmutableMap(); @@ -85,6 +90,16 @@ export default function antennas(state = initialState, action) { return state.updateIn([action.antennaId, 'keywords', 'exclude_keywords'], keywords => (ImmutableList(keywords || [])).filterNot(keyword => keyword === action.keyword)); case ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS: return state.setIn([action.id, 'keywords'], ImmutableMap({ keywords: ImmutableList(action.keywords.keywords), exclude_keywords: ImmutableList(action.keywords.exclude_keywords) })); + case ANTENNA_EDITOR_ADD_TAG_SUCCESS: + return state.setIn([action.antennaId, 'tags_count'], state.getIn([action.antennaId, 'tags_count']) + 1).updateIn([action.antennaId, 'tags', 'tags'], tags => (ImmutableList(tags || [])).push(action.tag)); + case ANTENNA_EDITOR_REMOVE_TAG_SUCCESS: + return state.setIn([action.antennaId, 'tags_count'], state.getIn([action.antennaId, 'tags_count']) - 1).updateIn([action.antennaId, 'tags', 'tags'], tags => (ImmutableList(tags || [])).filterNot(tag => tag === action.tag)); + case ANTENNA_EDITOR_ADD_EXCLUDE_TAG_SUCCESS: + return state.updateIn([action.antennaId, 'tags', 'exclude_tags'], tags => (ImmutableList(tags || [])).push(action.tag)); + case ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_SUCCESS: + return state.updateIn([action.antennaId, 'tags', 'exclude_tags'], tags => (ImmutableList(tags || [])).filterNot(tag => tag === action.tag)); + case ANTENNA_EDITOR_FETCH_TAGS_SUCCESS: + return state.setIn([action.id, 'tags'], ImmutableMap({ tags: ImmutableList(action.tags.tags), exclude_tags: ImmutableList(action.tags.exclude_tags) })); default: return state; } diff --git a/app/models/antenna.rb b/app/models/antenna.rb index a52e78192d..73b79c9c97 100644 --- a/app/models/antenna.rb +++ b/app/models/antenna.rb @@ -37,6 +37,7 @@ class Antenna < ApplicationRecord has_many :antenna_domains, inverse_of: :antenna, dependent: :destroy has_many :antenna_tags, inverse_of: :antenna, dependent: :destroy + has_many :tags, through: :antenna_tags has_many :antenna_accounts, inverse_of: :antenna, dependent: :destroy has_many :accounts, through: :antenna_accounts diff --git a/config/routes/api.rb b/config/routes/api.rb index 04db787e69..4739d8eaf1 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -210,9 +210,11 @@ namespace :api, format: false do resource :accounts, only: [:show, :create, :destroy], controller: 'antennas/accounts' resource :domains, only: [:show, :create, :destroy], controller: 'antennas/domains' resource :keywords, only: [:show, :create, :destroy], controller: 'antennas/keywords' + resource :tags, only: [:show, :create, :destroy], controller: 'antennas/tags' resource :exclude_accounts, only: [:show, :create, :destroy], controller: 'antennas/exclude_accounts' resource :exclude_domains, only: [:create, :destroy], controller: 'antennas/exclude_domains' resource :exclude_keywords, only: [:create, :destroy], controller: 'antennas/exclude_keywords' + resource :exclude_tags, only: [:create, :destroy], controller: 'antennas/exclude_tags' end resources :circles, only: [:index, :create, :show, :update, :destroy] do From 197f0b8ea3acb3a6bf132cfa3ade2714f0298448 Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 22 Aug 2023 07:42:26 +0900 Subject: [PATCH 3/8] Fix removing circle confirm message --- app/javascript/mastodon/features/circles/index.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/mastodon/features/circles/index.jsx b/app/javascript/mastodon/features/circles/index.jsx index 6b3726fb20..1b83876827 100644 --- a/app/javascript/mastodon/features/circles/index.jsx +++ b/app/javascript/mastodon/features/circles/index.jsx @@ -24,8 +24,8 @@ import NewCircleForm from './components/new_circle_form'; const messages = defineMessages({ heading: { id: 'column.circles', defaultMessage: 'Circles' }, subheading: { id: 'circles.subheading', defaultMessage: 'Your circles' }, - deleteMessage: { id: 'circles.subheading', defaultMessage: 'Your circles' }, - deleteConfirm: { id: 'circles.subheading', defaultMessage: 'Your circles' }, + deleteMessage: { id: 'confirmations.delete_circle.message', defaultMessage: 'Are you sure you want to permanently delete this circle?' }, + deleteConfirm: { id: 'confirmations.delete_circle.confirm', defaultMessage: 'Delete' }, }); const getOrderedCircles = createSelector([state => state.get('circles')], circles => { From 5daf9cdf6ef99438dfcd155df1211c7094817823 Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 22 Aug 2023 08:20:51 +0900 Subject: [PATCH 4/8] Add keep_privacy setting --- app/javascript/mastodon/reducers/compose.js | 14 ++++++++++++-- app/models/concerns/has_user_settings.rb | 4 ++++ app/models/user_settings.rb | 1 + app/serializers/initial_state_serializer.rb | 1 + app/serializers/rest/preferences_serializer.rb | 5 +++++ app/services/post_status_service.rb | 2 ++ .../settings/preferences/other/show.html.haml | 3 +++ config/locales/simple_form.en.yml | 1 + config/locales/simple_form.ja.yml | 3 ++- 9 files changed, 31 insertions(+), 3 deletions(-) diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 16d240c5a3..f06696f160 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -90,6 +90,7 @@ const initialState = ImmutableMap({ suggestion_token: null, suggestions: ImmutableList(), default_privacy: 'public', + stay_privacy: false, default_searchability: 'private', default_sensitive: false, default_language: 'en', @@ -103,6 +104,7 @@ const initialState = ImmutableMap({ focusY: 0, dirty: false, }), + posted_on_this_session: false, }); const initialPoll = ImmutableMap({ @@ -130,15 +132,23 @@ function clearAll(state) { map.set('markdown', false); map.set('is_submitting', false); map.set('is_changing_upload', false); + if (!state.get('stay_privacy') || state.get('in_reply_to') || !state.get('posted_on_this_session')) { + map.set('privacy', state.get('default_privacy')); + map.set('circle_id', null); + } + if (state.get('stay_privacy') && !state.get('in_reply_to')) { + map.set('default_privacy', state.get('privacy')); + } + if (!state.get('in_reply_to')) { + map.set('posted_on_this_session', true); + } map.set('in_reply_to', null); - map.set('privacy', state.get('default_privacy')); map.set('searchability', state.get('default_searchability')); map.set('sensitive', state.get('default_sensitive')); map.set('language', state.get('default_language')); map.update('media_attachments', list => list.clear()); map.set('poll', null); map.set('idempotencyKey', uuid()); - map.set('circle_id', null); }); } diff --git a/app/models/concerns/has_user_settings.rb b/app/models/concerns/has_user_settings.rb index 0fde83954b..b602081988 100644 --- a/app/models/concerns/has_user_settings.rb +++ b/app/models/concerns/has_user_settings.rb @@ -171,6 +171,10 @@ module HasUserSettings settings['default_privacy'] || (account.locked? ? 'private' : 'public') end + def setting_stay_privacy + settings['stay_privacy'] + end + def setting_default_reblog_privacy settings['default_reblog_privacy'] || 'unset' end diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb index bd85526818..40c146f596 100644 --- a/app/models/user_settings.rb +++ b/app/models/user_settings.rb @@ -21,6 +21,7 @@ class UserSettings setting :default_language, default: nil setting :default_sensitive, default: false setting :default_privacy, default: nil, in: %w(public public_unlisted login unlisted private) + setting :stay_privacy, default: false setting :default_reblog_privacy, default: nil setting :default_searchability, default: :direct, in: %w(public private direct limited) setting :disallow_unlisted_public_searchability, default: false diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 6596e2142f..06e1bf2393 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -73,6 +73,7 @@ class InitialStateSerializer < ActiveModel::Serializer if object.current_account store[:me] = object.current_account.id.to_s store[:default_privacy] = object.visibility || object.current_account.user.setting_default_privacy + store[:stay_privacy] = object.current_account.user.setting_stay_privacy store[:default_searchability] = object.searchability || object.current_account.user.setting_default_searchability store[:default_sensitive] = object.current_account.user.setting_default_sensitive store[:default_language] = object.current_account.user.preferred_posting_language diff --git a/app/serializers/rest/preferences_serializer.rb b/app/serializers/rest/preferences_serializer.rb index c63a33b828..d98d5fcaec 100644 --- a/app/serializers/rest/preferences_serializer.rb +++ b/app/serializers/rest/preferences_serializer.rb @@ -2,6 +2,7 @@ class REST::PreferencesSerializer < ActiveModel::Serializer attribute :posting_default_privacy, key: 'posting:default:visibility' + attribute :posting_stay_privacy, key: 'posting:keep:visibility' attribute :posting_default_searchability, key: 'posting:default:searchability' attribute :posting_default_sensitive, key: 'posting:default:sensitive' attribute :posting_default_language, key: 'posting:default:language' @@ -14,6 +15,10 @@ class REST::PreferencesSerializer < ActiveModel::Serializer object.user.setting_default_privacy end + def posting_stay_privacy + object.user.setting_stay_privacy + end + def posting_default_searchability object.user.setting_default_searchability end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 6b7a75e111..095079d0b6 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -169,6 +169,8 @@ class PostStatusService < BaseService end def postprocess_status! + @account.user.update!(settings_attributes: { default_privacy: @options[:visibility] }) if @account.user&.setting_stay_privacy && !@status.reply? && %i(public public_unlisted login unlisted private).include?(@status.visibility.to_sym) + process_hashtags_service.call(@status) ProcessReferencesWorker.perform_async(@status.id, @reference_ids, []) Trends.tags.register(@status) diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml index c8962ff4e1..99c3e0b2bc 100644 --- a/app/views/settings/preferences/other/show.html.haml +++ b/app/views/settings/preferences/other/show.html.haml @@ -27,6 +27,9 @@ .fields-group.fields-row__column.fields-row__column-6 = ff.input :default_language, collection: [nil] + filterable_languages, wrapper: :with_label, label_method: ->(locale) { locale.nil? ? I18n.t('statuses.default_language') : native_locale_name(locale) }, required: false, include_blank: false, hint: false, label: I18n.t('simple_form.labels.defaults.setting_default_language') + .fields-group + = ff.input :stay_privacy, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_stay_privacy') + .fields-group = ff.input :'web.enable_login_privacy', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_enable_login_privacy'), hint: false diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 82f71de553..adbd99c35c 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -245,6 +245,7 @@ en: setting_reject_unlisted_subscription: Reject sending unlisted posts to Misskey, Calckey setting_send_without_domain_blocks: Send your post to all server with administrator set as rejecting-post-server for protect you [DEPRECATED] setting_show_application: Disclose application used to send posts + setting_stay_privacy: Not change privacy after post setting_stop_emoji_reaction_streaming: Disable stamp streamings setting_system_font_ui: Use system's default font setting_theme: Site theme diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index be070e2198..cb6a7bd1c1 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -247,13 +247,14 @@ ja: setting_hide_network: 繋がりを隠す setting_hide_recent_emojis: 絵文字ピッカーで最近使用した絵文字を隠す(リアクションデッキのみを表示する) setting_hide_statuses_count: 投稿数を隠す + setting_stay_privacy: 投稿時に公開範囲を保存する setting_noai: 自分のコンテンツのAI学習利用に対して不快感を表明する - setting_public_post_to_unlisted: サードパーティアプリから投稿するとき、公開投稿をローカル公開に変更する setting_reduce_motion: アニメーションの動きを減らす setting_reject_public_unlisted_subscription: Misskey系サーバーに「ローカル公開」投稿を「フォロワーのみ」に変換して配送する setting_reject_unlisted_subscription: Misskey系サーバーに「未収載」投稿を「フォロワーのみ」に変換して配送する setting_send_without_domain_blocks: 管理人の設定した配送停止設定を拒否する (非推奨) setting_show_application: 送信したアプリを開示する + setting_stay_privacy: 投稿時に公開範囲を保存する setting_stop_emoji_reaction_streaming: スタンプのストリーミングを停止する setting_system_font_ui: システムのデフォルトフォントを使う setting_theme: サイトテーマ From 96ee6813c859ec73e952e584fc486a75ae24a8f2 Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 22 Aug 2023 08:37:17 +0900 Subject: [PATCH 5/8] Fix test --- spec/services/post_status_service_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index 62a67c393e..91fee989ab 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -168,7 +168,7 @@ RSpec.describe PostStatusService, type: :service do status = subject.call(account, text: 'test status update') expect(ProcessMentionsService).to have_received(:new) - expect(mention_service).to have_received(:call).with(status, limited_type: '', save_records: false) + expect(mention_service).to have_received(:call).with(status, limited_type: '', circle: nil, save_records: false) end it 'mutual visibility' do From bcf90e03af2d932887cf946d78bd9d942f36e89f Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 22 Aug 2023 09:21:24 +0900 Subject: [PATCH 6/8] Improve db query with stay_privacy --- app/services/post_status_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 095079d0b6..9404168c2b 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -169,7 +169,7 @@ class PostStatusService < BaseService end def postprocess_status! - @account.user.update!(settings_attributes: { default_privacy: @options[:visibility] }) if @account.user&.setting_stay_privacy && !@status.reply? && %i(public public_unlisted login unlisted private).include?(@status.visibility.to_sym) + @account.user.update!(settings_attributes: { default_privacy: @options[:visibility] }) if @account.user&.setting_stay_privacy && !@status.reply? && %i(public public_unlisted login unlisted private).include?(@status.visibility.to_sym) && @status.visibility.to_s != @account.user&.setting_default_privacy process_hashtags_service.call(@status) ProcessReferencesWorker.perform_async(@status.id, @reference_ids, []) From 083b5d32aefe2910f5db3492f233e8bb85d9a366 Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 22 Aug 2023 11:44:12 +0900 Subject: [PATCH 7/8] Add limited post tests --- spec/models/account_statuses_filter_spec.rb | 28 ++++++++++++++++++++- spec/models/public_feed_spec.rb | 3 +++ spec/policies/status_policy_spec.rb | 28 +++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/spec/models/account_statuses_filter_spec.rb b/spec/models/account_statuses_filter_spec.rb index 8f388dc67f..e6fd64acef 100644 --- a/spec/models/account_statuses_filter_spec.rb +++ b/spec/models/account_statuses_filter_spec.rb @@ -47,11 +47,13 @@ RSpec.describe AccountStatusesFilter do status!(:login) status!(:private) status!(:direct) + status!(:limited) status_with_parent!(:public) status_with_reblog!(:public) status_with_tag!(:public, tag) status_with_mention!(:direct) status_with_media_attachment!(:public) + status_with_mention!(:limited) end shared_examples 'filter params' do @@ -123,7 +125,7 @@ RSpec.describe AccountStatusesFilter do let(:current_account) { account } it 'returns everything' do - expect(subject.results.pluck(:visibility).uniq).to match_array %w(direct private login unlisted public_unlisted public) + expect(subject.results.pluck(:visibility).uniq).to match_array %w(direct private login unlisted public_unlisted public limited) end it 'returns replies' do @@ -164,6 +166,30 @@ RSpec.describe AccountStatusesFilter do end end + context 'when there is a direct status mentioning other user' do + let!(:direct_status) { status_with_mention!(:direct) } + + it 'not returns the direct status' do + expect(subject.results.pluck(:id)).to_not include(direct_status.id) + end + end + + context 'when there is a limited status mentioning the non-follower' do + let!(:limited_status) { status_with_mention!(:limited, current_account) } + + it 'returns the limited status' do + expect(subject.results.pluck(:id)).to include(limited_status.id) + end + end + + context 'when there is a limited status mentioning other user' do + let!(:limited_status) { status_with_mention!(:limited) } + + it 'not returns the limited status' do + expect(subject.results.pluck(:id)).to_not include(limited_status.id) + end + end + it_behaves_like 'filter params' end diff --git a/spec/models/public_feed_spec.rb b/spec/models/public_feed_spec.rb index 6107bc3ed0..965e293c5e 100644 --- a/spec/models/public_feed_spec.rb +++ b/spec/models/public_feed_spec.rb @@ -16,6 +16,7 @@ RSpec.describe PublicFeed do let!(:unlisted_status) { Fabricate(:status, visibility: :unlisted) } let!(:private_status) { Fabricate(:status, visibility: :private) } let!(:direct_status) { Fabricate(:status, visibility: :direct) } + let!(:limited_status) { Fabricate(:status, visibility: :limited) } it 'without user' do expect(subject).to include(public_status.id) @@ -23,6 +24,7 @@ RSpec.describe PublicFeed do expect(subject).to_not include(unlisted_status.id) expect(subject).to_not include(private_status.id) expect(subject).to_not include(direct_status.id) + expect(subject).to_not include(limited_status.id) end context 'with user' do @@ -34,6 +36,7 @@ RSpec.describe PublicFeed do expect(subject).to_not include(unlisted_status.id) expect(subject).to_not include(private_status.id) expect(subject).to_not include(direct_status.id) + expect(subject).to_not include(limited_status.id) end end end diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb index 36ac8d8027..271c70804b 100644 --- a/spec/policies/status_policy_spec.rb +++ b/spec/policies/status_policy_spec.rb @@ -57,6 +57,34 @@ RSpec.describe StatusPolicy, type: :model do expect(subject).to_not permit(viewer, status) end + it 'grants access when limited and account is viewer' do + status.visibility = :limited + + expect(subject).to permit(status.account, status) + end + + it 'grants access when limited and viewer is mentioned' do + status.visibility = :limited + status.mentions = [Fabricate(:mention, account: alice)] + + expect(subject).to permit(alice, status) + end + + it 'grants access when limited and non-owner viewer is mentioned and mentions are loaded' do + status.visibility = :limited + status.mentions = [Fabricate(:mention, account: bob)] + status.mentions.load + + expect(subject).to permit(bob, status) + end + + it 'denies access when limited and viewer is not mentioned' do + viewer = Fabricate(:account) + status.visibility = :limited + + expect(subject).to_not permit(viewer, status) + end + it 'grants access when private and account is viewer' do status.visibility = :private From a0843948aa8593948f4d5ad6d42f0e412246a109 Mon Sep 17 00:00:00 2001 From: KMY Date: Tue, 22 Aug 2023 13:02:45 +0900 Subject: [PATCH 8/8] Add antenna/list privacy test --- .../fabricators/antenna_account_fabricator.rb | 5 + spec/fabricators/circle_account_fabricator.rb | 3 + spec/fabricators/circle_fabricator.rb | 6 + .../services/fan_out_on_write_service_spec.rb | 157 +++++++++++++++++- 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 spec/fabricators/antenna_account_fabricator.rb create mode 100644 spec/fabricators/circle_account_fabricator.rb create mode 100644 spec/fabricators/circle_fabricator.rb diff --git a/spec/fabricators/antenna_account_fabricator.rb b/spec/fabricators/antenna_account_fabricator.rb new file mode 100644 index 0000000000..70d4067d20 --- /dev/null +++ b/spec/fabricators/antenna_account_fabricator.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Fabricator(:antenna_account) do + exclude false +end diff --git a/spec/fabricators/circle_account_fabricator.rb b/spec/fabricators/circle_account_fabricator.rb new file mode 100644 index 0000000000..2de537bc31 --- /dev/null +++ b/spec/fabricators/circle_account_fabricator.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +Fabricator(:circle_account) diff --git a/spec/fabricators/circle_fabricator.rb b/spec/fabricators/circle_fabricator.rb new file mode 100644 index 0000000000..874b41fa13 --- /dev/null +++ b/spec/fabricators/circle_fabricator.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +Fabricator(:circle) do + account { Fabricate.build(:account) } + title 'MyString' +end diff --git a/spec/services/fan_out_on_write_service_spec.rb b/spec/services/fan_out_on_write_service_spec.rb index 3b554f9ea3..1c81880432 100644 --- a/spec/services/fan_out_on_write_service_spec.rb +++ b/spec/services/fan_out_on_write_service_spec.rb @@ -6,15 +6,23 @@ RSpec.describe FanOutOnWriteService, type: :service do subject { described_class.new } let(:last_active_at) { Time.now.utc } - let(:status) { Fabricate(:status, account: alice, visibility: visibility, text: 'Hello @bob #hoge') } + let(:searchability) { 'public' } + let(:status) { Fabricate(:status, account: alice, visibility: visibility, searchability: searchability, text: 'Hello @bob #hoge') } let!(:alice) { Fabricate(:user, current_sign_in_at: last_active_at).account } let!(:bob) { Fabricate(:user, current_sign_in_at: last_active_at, account_attributes: { username: 'bob' }).account } let!(:tom) { Fabricate(:user, current_sign_in_at: last_active_at).account } + let!(:ohagi) { Fabricate(:user, current_sign_in_at: last_active_at).account } + + let!(:list) { nil } + let!(:empty_list) { nil } + let!(:antenna) { nil } + let!(:empty_antenna) { nil } before do bob.follow!(alice) tom.follow!(alice) + ohagi.follow!(bob) ProcessMentionsService.new.call(status) ProcessHashtagsService.new.call(status) @@ -28,6 +36,26 @@ RSpec.describe FanOutOnWriteService, type: :service do HomeFeed.new(account).get(10).map(&:id) end + def list_feed_of(list) + ListFeed.new(list).get(10).map(&:id) + end + + def antenna_feed_of(antenna) + AntennaFeed.new(antenna).get(10).map(&:id) + end + + def list_with_account(owner, target_account) + list = Fabricate(:list, account: owner) + Fabricate(:list_account, list: list, account: target_account) + list + end + + def antenna_with_account(owner, target_account) + antenna = Fabricate(:antenna, account: owner, any_accounts: false) + Fabricate(:antenna_account, antenna: antenna, account: target_account) + antenna + end + context 'when status is public' do let(:visibility) { 'public' } @@ -49,6 +77,26 @@ RSpec.describe FanOutOnWriteService, type: :service do expect(redis).to have_received(:publish).with('timeline:public', anything) expect(redis).to have_received(:publish).with('timeline:public:local', anything) end + + context 'with list' do + let!(:list) { list_with_account(bob, alice) } + let!(:empty_list) { Fabricate(:list, account: tom) } + + it 'is added to the list feed of list follower' do + expect(list_feed_of(list)).to include status.id + expect(list_feed_of(empty_list)).to_not include status.id + end + end + + context 'with antenna' do + let!(:antenna) { antenna_with_account(bob, alice) } + let!(:empty_antenna) { antenna_with_account(tom, bob) } + + it 'is added to the antenna feed of antenna follower' do + expect(antenna_feed_of(antenna)).to include status.id + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end end context 'when status is limited' do @@ -70,6 +118,26 @@ RSpec.describe FanOutOnWriteService, type: :service do expect(redis).to_not have_received(:publish).with('timeline:hashtag:hoge', anything) expect(redis).to_not have_received(:publish).with('timeline:public', anything) end + + context 'with list' do + let!(:list) { list_with_account(bob, alice) } + let!(:empty_list) { list_with_account(tom, alice) } + + it 'is added to the list feed of list follower' do + expect(list_feed_of(list)).to include status.id + expect(list_feed_of(empty_list)).to_not include status.id + end + end + + context 'with antenna' do + let!(:antenna) { antenna_with_account(bob, alice) } + let!(:empty_antenna) { antenna_with_account(tom, alice) } + + it 'is added to the list feed of list follower' do + expect(antenna_feed_of(antenna)).to include status.id + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end end context 'when status is private' do @@ -88,6 +156,73 @@ RSpec.describe FanOutOnWriteService, type: :service do expect(redis).to_not have_received(:publish).with('timeline:hashtag:hoge', anything) expect(redis).to_not have_received(:publish).with('timeline:public', anything) end + + context 'with list' do + let!(:list) { list_with_account(bob, alice) } + let!(:empty_list) { list_with_account(ohagi, bob) } + + it 'is added to the list feed of list follower' do + expect(list_feed_of(list)).to include status.id + expect(list_feed_of(empty_list)).to_not include status.id + end + end + + context 'with antenna' do + let!(:antenna) { antenna_with_account(bob, alice) } + let!(:empty_antenna) { antenna_with_account(ohagi, alice) } + + it 'is added to the list feed of list follower' do + expect(antenna_feed_of(antenna)).to include status.id + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end + end + + context 'when status is unlisted' do + let(:visibility) { 'unlisted' } + + it 'is added to the home feed of its author' do + expect(home_feed_of(alice)).to include status.id + end + + it 'is added to the home feed of a follower' do + expect(home_feed_of(bob)).to include status.id + expect(home_feed_of(tom)).to include status.id + end + + it 'is not broadcast publicly' do + expect(redis).to have_received(:publish).with('timeline:hashtag:hoge', anything) + expect(redis).to_not have_received(:publish).with('timeline:public', anything) + end + + context 'with list' do + let!(:list) { list_with_account(bob, alice) } + let!(:empty_list) { list_with_account(ohagi, bob) } + + it 'is added to the list feed of list follower' do + expect(list_feed_of(list)).to include status.id + expect(list_feed_of(empty_list)).to_not include status.id + end + end + + context 'with antenna' do + let!(:antenna) { antenna_with_account(bob, alice) } + let!(:empty_antenna) { antenna_with_account(ohagi, alice) } + + it 'is added to the list feed of list follower' do + expect(antenna_feed_of(antenna)).to include status.id + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end + + context 'with non-public searchability' do + let(:searchability) { 'direct' } + + it 'hashtag-timeline is not detected' do + expect(redis).to_not have_received(:publish).with('timeline:hashtag:hoge', anything) + expect(redis).to_not have_received(:publish).with('timeline:public', anything) + end + end end context 'when status is direct' do @@ -109,5 +244,25 @@ RSpec.describe FanOutOnWriteService, type: :service do expect(redis).to_not have_received(:publish).with('timeline:hashtag:hoge', anything) expect(redis).to_not have_received(:publish).with('timeline:public', anything) end + + context 'with list' do + let!(:list) { list_with_account(bob, alice) } + let!(:empty_list) { list_with_account(ohagi, bob) } + + it 'is added to the list feed of list follower' do + expect(list_feed_of(list)).to_not include status.id + expect(list_feed_of(empty_list)).to_not include status.id + end + end + + context 'with antenna' do + let!(:antenna) { antenna_with_account(bob, alice) } + let!(:empty_antenna) { antenna_with_account(ohagi, alice) } + + it 'is added to the list feed of list follower' do + expect(antenna_feed_of(antenna)).to_not include status.id + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end end end