diff --git a/app/controllers/api/v1/antennas/domains_controller.rb b/app/controllers/api/v1/antennas/domains_controller.rb new file mode 100644 index 0000000000..554b8d613c --- /dev/null +++ b/app/controllers/api/v1/antennas/domains_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class Api::V1::Antennas::DomainsController < 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 + @domains = load_domains + @exclude_domains = load_exclude_domains + render json: { domains: @domains, exclude_domains: @exclude_domains } + end + + def create + ApplicationRecord.transaction do + domains.each do |domain| + @antenna.antenna_domains.create!(name: domain, exclude: false) + @antenna.update!(any_domains: false) if @antenna.any_domains + end + end + + render_empty + end + + def destroy + AntennaDomain.where(antenna: @antenna, name: domains).destroy_all + @antenna.update!(any_domains: true) unless @antenna.antenna_domains.where(exclude: false).any? + render_empty + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:antenna_id]) + end + + def load_domains + @antenna.antenna_domains.pluck(:name) + end + + def load_exclude_domains + @antenna.exclude_domains || [] + end + + def domains + Array(resource_params[:domains]) + end + + def resource_params + params.permit(domains: []) + end +end diff --git a/app/controllers/api/v1/antennas/keywords_controller.rb b/app/controllers/api/v1/antennas/keywords_controller.rb new file mode 100644 index 0000000000..94b9c396cc --- /dev/null +++ b/app/controllers/api/v1/antennas/keywords_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class Api::V1::Antennas::KeywordsController < 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 + @keywords = load_keywords + @exclude_keywords = load_exclude_keywords + render json: { keywords: @keywords, exclude_keywords: @exclude_keywords } + end + + def create + new_keywords = @antenna.keywords || [] + keywords.each do |keyword| + raise Mastodon::ValidationError, I18n.t('antennas.errors.same_keyword') if new_keywords.include?(keyword) + + new_keywords << keyword + end + + @antenna.update!(keywords: new_keywords, any_keywords: new_keywords.empty?) + + render_empty + end + + def destroy + new_keywords = @antenna.keywords || [] + new_keywords -= keywords + + @antenna.update!(keywords: new_keywords, any_keywords: new_keywords.empty?) + + render_empty + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:antenna_id]) + end + + def load_keywords + @antenna.keywords || [] + end + + def load_exclude_keywords + @antenna.exclude_keywords || [] + end + + def keywords + Array(resource_params[:keywords]) + end + + def resource_params + params.permit(keywords: []) + end +end diff --git a/app/javascript/mastodon/actions/antennas.js b/app/javascript/mastodon/actions/antennas.js index 8cd1e7d90d..37e6b13802 100644 --- a/app/javascript/mastodon/actions/antennas.js +++ b/app/javascript/mastodon/actions/antennas.js @@ -43,6 +43,30 @@ export const ANTENNA_EDITOR_REMOVE_REQUEST = 'ANTENNA_EDITOR_REMOVE_REQUEST'; export const ANTENNA_EDITOR_REMOVE_SUCCESS = 'ANTENNA_EDITOR_REMOVE_SUCCESS'; export const ANTENNA_EDITOR_REMOVE_FAIL = 'ANTENNA_EDITOR_REMOVE_FAIL'; +export const ANTENNA_EDITOR_FETCH_DOMAINS_REQUEST = 'ANTENNA_EDITOR_FETCH_DOMAINS_REQUEST'; +export const ANTENNA_EDITOR_FETCH_DOMAINS_SUCCESS = 'ANTENNA_EDITOR_FETCH_DOMAINS_SUCCESS'; +export const ANTENNA_EDITOR_FETCH_DOMAINS_FAIL = 'ANTENNA_EDITOR_FETCH_DOMAINS_FAIL'; + +export const ANTENNA_EDITOR_ADD_DOMAIN_REQUEST = 'ANTENNA_EDITOR_ADD_DOMAIN_REQUEST'; +export const ANTENNA_EDITOR_ADD_DOMAIN_SUCCESS = 'ANTENNA_EDITOR_ADD_DOMAIN_SUCCESS'; +export const ANTENNA_EDITOR_ADD_DOMAIN_FAIL = 'ANTENNA_EDITOR_ADD_DOMAIN_FAIL'; + +export const ANTENNA_EDITOR_REMOVE_DOMAIN_REQUEST = 'ANTENNA_EDITOR_REMOVE_DOMAIN_REQUEST'; +export const ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS = 'ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS'; +export const ANTENNA_EDITOR_REMOVE_DOMAIN_FAIL = 'ANTENNA_EDITOR_REMOVE_DOMAIN_FAIL'; + +export const ANTENNA_EDITOR_FETCH_KEYWORDS_REQUEST = 'ANTENNA_EDITOR_FETCH_KEYWORDS_REQUEST'; +export const ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS = 'ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS'; +export const ANTENNA_EDITOR_FETCH_KEYWORDS_FAIL = 'ANTENNA_EDITOR_FETCH_KEYWORDS_FAIL'; + +export const ANTENNA_EDITOR_ADD_KEYWORD_REQUEST = 'ANTENNA_EDITOR_ADD_KEYWORD_REQUEST'; +export const ANTENNA_EDITOR_ADD_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_ADD_KEYWORD_SUCCESS'; +export const ANTENNA_EDITOR_ADD_KEYWORD_FAIL = 'ANTENNA_EDITOR_ADD_KEYWORD_FAIL'; + +export const ANTENNA_EDITOR_REMOVE_KEYWORD_REQUEST = 'ANTENNA_EDITOR_REMOVE_KEYWORD_REQUEST'; +export const ANTENNA_EDITOR_REMOVE_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_REMOVE_KEYWORD_SUCCESS'; +export const ANTENNA_EDITOR_REMOVE_KEYWORD_FAIL = 'ANTENNA_EDITOR_REMOVE_KEYWORD_FAIL'; + export const ANTENNA_ADDER_RESET = 'ANTENNA_ADDER_RESET'; export const ANTENNA_ADDER_SETUP = 'ANTENNA_ADDER_SETUP'; @@ -292,6 +316,110 @@ export const addToAntennaFail = (antennaId, accountId, error) => ({ error, }); +export const fetchAntennaDomains = antennaId => (dispatch, getState) => { + dispatch(fetchAntennaDomainsRequest(antennaId)); + + api(getState).get(`/api/v1/antennas/${antennaId}/domains`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(fetchAntennaDomainsSuccess(antennaId, data)); + }).catch(err => dispatch(fetchAntennaDomainsFail(antennaId, err))); +}; + +export const fetchAntennaDomainsRequest = id => ({ + type: ANTENNA_EDITOR_FETCH_DOMAINS_REQUEST, + id, +}); + +export const fetchAntennaDomainsSuccess = (id, domains) => ({ + type: ANTENNA_EDITOR_FETCH_DOMAINS_SUCCESS, + id, + domains, +}); + +export const fetchAntennaDomainsFail = (id, error) => ({ + type: ANTENNA_EDITOR_FETCH_DOMAINS_FAIL, + id, + error, +}); + +export const addDomainToAntenna = (antennaId, domain) => (dispatch, getState) => { + dispatch(addDomainToAntennaRequest(antennaId, domain)); + + api(getState).post(`/api/v1/antennas/${antennaId}/domains`, { domains: [domain] }) + .then(() => dispatch(addDomainToAntennaSuccess(antennaId, domain))) + .catch(err => dispatch(addDomainToAntennaFail(antennaId, domain, err))); +}; + +export const addDomainToAntennaRequest = (antennaId, domain) => ({ + type: ANTENNA_EDITOR_ADD_DOMAIN_REQUEST, + antennaId, + domain, +}); + +export const addDomainToAntennaSuccess = (antennaId, domain) => ({ + type: ANTENNA_EDITOR_ADD_DOMAIN_SUCCESS, + antennaId, + domain, +}); + +export const addDomainToAntennaFail = (antennaId, domain, error) => ({ + type: ANTENNA_EDITOR_ADD_DOMAIN_FAIL, + antennaId, + domain, + error, +}); + +export const fetchAntennaKeywords = antennaId => (dispatch, getState) => { + dispatch(fetchAntennaKeywordsRequest(antennaId)); + + api(getState).get(`/api/v1/antennas/${antennaId}/keywords`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(fetchAntennaKeywordsSuccess(antennaId, data)); + }).catch(err => dispatch(fetchAntennaKeywordsFail(antennaId, err))); +}; + +export const fetchAntennaKeywordsRequest = id => ({ + type: ANTENNA_EDITOR_FETCH_KEYWORDS_REQUEST, + id, +}); + +export const fetchAntennaKeywordsSuccess = (id, keywords) => ({ + type: ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS, + id, + keywords, +}); + +export const fetchAntennaKeywordsFail = (id, error) => ({ + type: ANTENNA_EDITOR_FETCH_KEYWORDS_FAIL, + id, + error, +}); + +export const addKeywordToAntenna = (antennaId, keyword) => (dispatch, getState) => { + dispatch(addKeywordToAntennaRequest(antennaId, keyword)); + + api(getState).post(`/api/v1/antennas/${antennaId}/keywords`, { keywords: [keyword] }) + .then(() => dispatch(addKeywordToAntennaSuccess(antennaId, keyword))) + .catch(err => dispatch(addKeywordToAntennaFail(antennaId, keyword, err))); +}; + +export const addKeywordToAntennaRequest = (antennaId, keyword) => ({ + type: ANTENNA_EDITOR_ADD_KEYWORD_REQUEST, + antennaId, + keyword, +}); + +export const addKeywordToAntennaSuccess = (antennaId, keyword) => ({ + type: ANTENNA_EDITOR_ADD_KEYWORD_SUCCESS, + antennaId, + keyword, +}); + +export const addKeywordToAntennaFail = (antennaId, keyword, error) => ({ + type: ANTENNA_EDITOR_ADD_KEYWORD_FAIL, + antennaId, + keyword, + error, +}); + export const removeFromAntennaEditor = accountId => (dispatch, getState) => { dispatch(removeFromAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId)); }; @@ -323,6 +451,60 @@ export const removeFromAntennaFail = (antennaId, accountId, error) => ({ error, }); +export const removeDomainFromAntenna = (antennaId, domain) => (dispatch, getState) => { + dispatch(removeDomainFromAntennaRequest(antennaId, domain)); + + api(getState).delete(`/api/v1/antennas/${antennaId}/domains`, { params: { domains: [domain] } }) + .then(() => dispatch(removeDomainFromAntennaSuccess(antennaId, domain))) + .catch(err => dispatch(removeDomainFromAntennaFail(antennaId, domain, err))); +}; + +export const removeDomainFromAntennaRequest = (antennaId, domain) => ({ + type: ANTENNA_EDITOR_REMOVE_DOMAIN_REQUEST, + antennaId, + domain, +}); + +export const removeDomainFromAntennaSuccess = (antennaId, domain) => ({ + type: ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS, + antennaId, + domain, +}); + +export const removeDomainFromAntennaFail = (antennaId, domain, error) => ({ + type: ANTENNA_EDITOR_REMOVE_DOMAIN_FAIL, + antennaId, + domain, + error, +}); + +export const removeKeywordFromAntenna = (antennaId, keyword) => (dispatch, getState) => { + dispatch(removeKeywordFromAntennaRequest(antennaId, keyword)); + + api(getState).delete(`/api/v1/antennas/${antennaId}/keywords`, { params: { keywords: [keyword] } }) + .then(() => dispatch(removeKeywordFromAntennaSuccess(antennaId, keyword))) + .catch(err => dispatch(removeKeywordFromAntennaFail(antennaId, keyword, err))); +}; + +export const removeKeywordFromAntennaRequest = (antennaId, keyword) => ({ + type: ANTENNA_EDITOR_REMOVE_KEYWORD_REQUEST, + antennaId, + keyword, +}); + +export const removeKeywordFromAntennaSuccess = (antennaId, keyword) => ({ + type: ANTENNA_EDITOR_REMOVE_KEYWORD_SUCCESS, + antennaId, + keyword, +}); + +export const removeKeywordFromAntennaFail = (antennaId, keyword, error) => ({ + type: ANTENNA_EDITOR_REMOVE_KEYWORD_FAIL, + antennaId, + keyword, + error, +}); + export const resetAntennaAdder = () => ({ type: ANTENNA_ADDER_RESET, }); diff --git a/app/javascript/mastodon/features/antenna_setting/components/text_list.jsx b/app/javascript/mastodon/features/antenna_setting/components/text_list.jsx new file mode 100644 index 0000000000..7f4d5b67ac --- /dev/null +++ b/app/javascript/mastodon/features/antenna_setting/components/text_list.jsx @@ -0,0 +1,96 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import Button from 'mastodon/components/button'; +import { Icon } from 'mastodon/components/icon'; +import { IconButton } from 'mastodon/components/icon_button'; + +class TextListItem extends PureComponent { + + static propTypes = { + icon: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + onRemove: PropTypes.func.isRequired, + }; + + handleRemove = () => { + this.props.onRemove(this.props.value); + }; + + render () { + const { icon, value } = this.props; + + return ( +
+ + {value} + +
+ ); + } + +} + +class TextList extends PureComponent { + + static propTypes = { + values: ImmutablePropTypes.list.isRequired, + value: PropTypes.string.isRequired, + disabled: PropTypes.bool, + intl: PropTypes.object.isRequired, + icon: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + onAdd: PropTypes.func.isRequired, + onRemove: PropTypes.func.isRequired, + }; + + handleChange = e => { + this.props.onChange(e.target.value); + }; + + handleAdd = () => { + this.props.onAdd(); + }; + + render () { + const { icon, value, values, disabled, label, title } = this.props; + + return ( +
+ {values.map((val) => ( + + ))} + +
+ + +
+ ); + } + +} + +export default connect()(injectIntl(TextList)); diff --git a/app/javascript/mastodon/features/antenna_setting/index.jsx b/app/javascript/mastodon/features/antenna_setting/index.jsx index ba85cc40c7..f98abb6409 100644 --- a/app/javascript/mastodon/features/antenna_setting/index.jsx +++ b/app/javascript/mastodon/features/antenna_setting/index.jsx @@ -5,13 +5,14 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { Helmet } from 'react-helmet'; +import { List as ImmutableList } from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import Select, { NonceProvider } from 'react-select'; import Toggle from 'react-toggle'; -import { fetchAntenna, deleteAntenna, updateAntenna } from 'mastodon/actions/antennas'; +import { fetchAntenna, deleteAntenna, updateAntenna, addDomainToAntenna, removeDomainFromAntenna, fetchAntennaDomains, fetchAntennaKeywords, removeKeywordFromAntenna, addKeywordToAntenna } from 'mastodon/actions/antennas'; import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; import { fetchLists } from 'mastodon/actions/lists'; import { openModal } from 'mastodon/actions/modal'; @@ -22,17 +23,25 @@ import { Icon } from 'mastodon/components/icon'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; +import TextList from './components/text_list'; + const messages = defineMessages({ deleteMessage: { id: 'confirmations.delete_antenna.message', defaultMessage: 'Are you sure you want to permanently delete this antenna?' }, deleteConfirm: { id: 'confirmations.delete_antenna.confirm', defaultMessage: 'Delete' }, editAccounts: { id: 'antennas.edit_accounts', defaultMessage: 'Edit accounts' }, noOptions: { id: 'antennas.select.no_options_message', defaultMessage: 'Empty lists' }, 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' }, + addDomainTitle: { id: 'antennas.add_domain', defaultMessage: 'Add domain' }, + addKeywordTitle: { id: 'antennas.add_keyword', defaultMessage: 'Add keyword' }, }); const mapStateToProps = (state, props) => ({ antenna: state.getIn(['antennas', props.params.id]), lists: state.get('lists'), + domains: state.getIn(['antennas', props.params.id, 'domains']) || ImmutableList(), + keywords: state.getIn(['antennas', props.params.id, 'keywords']) || ImmutableList(), }); class AntennaSetting extends PureComponent { @@ -48,9 +57,16 @@ class AntennaSetting extends PureComponent { multiColumn: PropTypes.bool, antenna: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]), lists: ImmutablePropTypes.list, + domains: ImmutablePropTypes.list, + keywords: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, }; + state = { + domainName: '', + keywordName: '', + }; + handlePin = () => { const { columnId, dispatch } = this.props; @@ -76,6 +92,8 @@ class AntennaSetting extends PureComponent { const { id } = this.props.params; dispatch(fetchAntenna(id)); + dispatch(fetchAntennaDomains(id)); + dispatch(fetchAntennaKeywords(id)); dispatch(fetchLists()); } @@ -85,6 +103,10 @@ class AntennaSetting extends PureComponent { if (id !== this.props.params.id) { dispatch(fetchAntenna(id)); + dispatch(fetchAntennaKeywords(id)); + dispatch(fetchAntennaDomains(id)); + dispatch(fetchAntennaKeywords(id)); + dispatch(fetchLists()); } } @@ -161,8 +183,26 @@ class AntennaSetting extends PureComponent { noOptionsMessage = () => this.props.intl.formatMessage(messages.noOptions); + onDomainNameChanged = (value) => this.setState({ domainName: value }); + + onDomainAdd = () => { + this.props.dispatch(addDomainToAntenna(this.props.params.id, this.state.domainName)); + this.setState({ domainName: '' }); + }; + + onDomainRemove = (value) => this.props.dispatch(removeDomainFromAntenna(this.props.params.id, value)); + + onKeywordNameChanged = (value) => this.setState({ keywordName: value }); + + onKeywordAdd = () => { + this.props.dispatch(addKeywordToAntenna(this.props.params.id, this.state.keywordName)); + this.setState({ keywordName: '' }); + }; + + onKeywordRemove = (value) => this.props.dispatch(removeKeywordFromAntenna(this.props.params.id, value)); + render () { - const { columnId, multiColumn, antenna, lists, intl } = this.props; + const { columnId, multiColumn, antenna, lists, domains, keywords, intl } = this.props; const { id } = this.props.params; const pinned = !!columnId; const title = antenna ? antenna.get('title') : id; @@ -293,8 +333,30 @@ class AntennaSetting extends PureComponent {