Add antenna editor page

This commit is contained in:
KMY 2023-08-19 09:31:45 +09:00
parent 87a8da93e8
commit f99dde6df1
25 changed files with 1187 additions and 122 deletions

View file

@ -1,13 +1,18 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::Antennas::AccountsController < Api::BaseController class Api::V1::Antennas::AccountsController < Api::BaseController
# before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show]
before_action -> { doorkeeper_authorize! :write, :'write:lists' } # , except: [:show] before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show]
before_action :require_user! before_action :require_user!
before_action :set_antenna before_action :set_antenna
# after_action :insert_pagination_headers, only: :show after_action :insert_pagination_headers, only: :show
def show
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
def create def create
ApplicationRecord.transaction do ApplicationRecord.transaction do
@ -32,6 +37,14 @@ class Api::V1::Antennas::AccountsController < Api::BaseController
@antenna = Antenna.where(account: current_account).find(params[:antenna_id]) @antenna = Antenna.where(account: current_account).find(params[:antenna_id])
end end
def load_accounts
if unlimited?
@antenna.accounts.without_suspended.includes(:account_stat).all
else
@antenna.accounts.without_suspended.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
end
end
def antenna_accounts def antenna_accounts
Account.find(account_ids) Account.find(account_ids)
end end

View file

@ -5,7 +5,7 @@ class Api::V1::AntennasController < Api::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show] before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show]
before_action :require_user! before_action :require_user!
before_action :set_antenna, except: [:index] before_action :set_antenna, except: [:index, :create]
rescue_from ArgumentError do |e| rescue_from ArgumentError do |e|
render json: { error: e.to_s }, status: 422 render json: { error: e.to_s }, status: 422
@ -20,9 +20,28 @@ class Api::V1::AntennasController < Api::BaseController
render json: @antenna, serializer: REST::AntennaSerializer render json: @antenna, serializer: REST::AntennaSerializer
end end
def create
@antenna = Antenna.create!(antenna_params.merge(account: current_account, list_id: 0))
render json: @antenna, serializer: REST::AntennaSerializer
end
def update
@antenna.update!(antenna_params)
render json: @antenna, serializer: REST::AntennaSerializer
end
def destroy
@antenna.destroy!
render_empty
end
private private
def set_antenna def set_antenna
@antenna = Antenna.where(account: current_account).find(params[:id]) @antenna = Antenna.where(account: current_account).find(params[:id])
end end
def antenna_params
params.permit(:title, :stl, :with_media_only, :ignore_reblog)
end
end end

View file

@ -1,29 +1,82 @@
import api from '../api'; import api from '../api';
import { showAlertForError } from './alerts';
import { importFetchedAccounts } from './importer'; import { importFetchedAccounts } from './importer';
export const ANTENNA_FETCH_REQUEST = 'ANTENNA_FETCH_REQUEST';
export const ANTENNA_FETCH_SUCCESS = 'ANTENNA_FETCH_SUCCESS';
export const ANTENNA_FETCH_FAIL = 'ANTENNA_FETCH_FAIL';
export const ANTENNAS_FETCH_REQUEST = 'ANTENNAS_FETCH_REQUEST'; export const ANTENNAS_FETCH_REQUEST = 'ANTENNAS_FETCH_REQUEST';
export const ANTENNAS_FETCH_SUCCESS = 'ANTENNAS_FETCH_SUCCESS'; export const ANTENNAS_FETCH_SUCCESS = 'ANTENNAS_FETCH_SUCCESS';
export const ANTENNAS_FETCH_FAIL = 'ANTENNAS_FETCH_FAIL'; export const ANTENNAS_FETCH_FAIL = 'ANTENNAS_FETCH_FAIL';
export const ANTENNA_EDITOR_TITLE_CHANGE = 'ANTENNA_EDITOR_TITLE_CHANGE';
export const ANTENNA_EDITOR_RESET = 'ANTENNA_EDITOR_RESET';
export const ANTENNA_EDITOR_SETUP = 'ANTENNA_EDITOR_SETUP';
export const ANTENNA_CREATE_REQUEST = 'ANTENNA_CREATE_REQUEST';
export const ANTENNA_CREATE_SUCCESS = 'ANTENNA_CREATE_SUCCESS';
export const ANTENNA_CREATE_FAIL = 'ANTENNA_CREATE_FAIL';
export const ANTENNA_UPDATE_REQUEST = 'ANTENNA_UPDATE_REQUEST';
export const ANTENNA_UPDATE_SUCCESS = 'ANTENNA_UPDATE_SUCCESS';
export const ANTENNA_UPDATE_FAIL = 'ANTENNA_UPDATE_FAIL';
export const ANTENNA_DELETE_REQUEST = 'ANTENNA_DELETE_REQUEST';
export const ANTENNA_DELETE_SUCCESS = 'ANTENNA_DELETE_SUCCESS';
export const ANTENNA_DELETE_FAIL = 'ANTENNA_DELETE_FAIL';
export const ANTENNA_ACCOUNTS_FETCH_REQUEST = 'ANTENNA_ACCOUNTS_FETCH_REQUEST'; export const ANTENNA_ACCOUNTS_FETCH_REQUEST = 'ANTENNA_ACCOUNTS_FETCH_REQUEST';
export const ANTENNA_ACCOUNTS_FETCH_SUCCESS = 'ANTENNA_ACCOUNTS_FETCH_SUCCESS'; export const ANTENNA_ACCOUNTS_FETCH_SUCCESS = 'ANTENNA_ACCOUNTS_FETCH_SUCCESS';
export const ANTENNA_ACCOUNTS_FETCH_FAIL = 'ANTENNA_ACCOUNTS_FETCH_FAIL'; export const ANTENNA_ACCOUNTS_FETCH_FAIL = 'ANTENNA_ACCOUNTS_FETCH_FAIL';
export const ANTENNA_EDITOR_ADD_ACCOUNT_REQUEST = 'ANTENNA_EDITOR_ADD_ACCOUNT_REQUEST'; export const ANTENNA_EDITOR_SUGGESTIONS_CHANGE = 'ANTENNA_EDITOR_SUGGESTIONS_CHANGE';
export const ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS = 'ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS'; export const ANTENNA_EDITOR_SUGGESTIONS_READY = 'ANTENNA_EDITOR_SUGGESTIONS_READY';
export const ANTENNA_EDITOR_ADD_ACCOUNT_FAIL = 'ANTENNA_EDITOR_ADD_ACCOUNT_FAIL'; export const ANTENNA_EDITOR_SUGGESTIONS_CLEAR = 'ANTENNA_EDITOR_SUGGESTIONS_CLEAR';
export const ANTENNA_EDITOR_REMOVE_ACCOUNT_REQUEST = 'ANTENNA_EDITOR_REMOVE_ACCOUNT_REQUEST'; export const ANTENNA_EDITOR_ADD_REQUEST = 'ANTENNA_EDITOR_ADD_REQUEST';
export const ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS = 'ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS'; export const ANTENNA_EDITOR_ADD_SUCCESS = 'ANTENNA_EDITOR_ADD_SUCCESS';
export const ANTENNA_EDITOR_REMOVE_ACCOUNT_FAIL = 'ANTENNA_EDITOR_REMOVE_ACCOUNT_FAIL'; export const ANTENNA_EDITOR_ADD_FAIL = 'ANTENNA_EDITOR_ADD_FAIL';
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_ADDER_RESET = 'ANTENNA_ADDER_RESET';
export const ANTENNA_ADDER_SETUP = 'ANTENNA_ADDER_SETUP';
export const ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST = 'ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST'; export const ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST = 'ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST';
export const ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS = 'ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS'; export const ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS = 'ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS';
export const ANTENNA_ADDER_ANTENNAS_FETCH_FAIL = 'ANTENNA_ADDER_ANTENNAS_FETCH_FAIL'; export const ANTENNA_ADDER_ANTENNAS_FETCH_FAIL = 'ANTENNA_ADDER_ANTENNAS_FETCH_FAIL';
export const ANTENNA_ADDER_RESET = 'ANTENNA_ADDER_RESET'; export const fetchAntenna = id => (dispatch, getState) => {
export const ANTENNA_ADDER_SETUP = 'ANTENNA_ADDER_SETUP'; if (getState().getIn(['antennas', id])) {
return;
}
dispatch(fetchAntennaRequest(id));
api(getState).get(`/api/v1/antennas/${id}`)
.then(({ data }) => dispatch(fetchAntennaSuccess(data)))
.catch(err => dispatch(fetchAntennaFail(id, err)));
};
export const fetchAntennaRequest = id => ({
type: ANTENNA_FETCH_REQUEST,
id,
});
export const fetchAntennaSuccess = antenna => ({
type: ANTENNA_FETCH_SUCCESS,
antenna,
});
export const fetchAntennaFail = (id, error) => ({
type: ANTENNA_FETCH_FAIL,
id,
error,
});
export const fetchAntennas = () => (dispatch, getState) => { export const fetchAntennas = () => (dispatch, getState) => {
dispatch(fetchAntennasRequest()); dispatch(fetchAntennasRequest());
@ -47,6 +100,113 @@ export const fetchAntennasFail = error => ({
error, error,
}); });
export const submitAntennaEditor = shouldReset => (dispatch, getState) => {
const antennaId = getState().getIn(['antennaEditor', 'antennaId']);
const title = getState().getIn(['antennaEditor', 'title']);
if (antennaId === null) {
dispatch(createAntenna(title, shouldReset));
} else {
dispatch(updateAntenna(antennaId, title, shouldReset));
}
};
export const setupAntennaEditor = antennaId => (dispatch, getState) => {
dispatch({
type: ANTENNA_EDITOR_SETUP,
antenna: getState().getIn(['antennas', antennaId]),
});
dispatch(fetchAntennaAccounts(antennaId));
};
export const changeAntennaEditorTitle = value => ({
type: ANTENNA_EDITOR_TITLE_CHANGE,
value,
});
export const createAntenna = (title, shouldReset) => (dispatch, getState) => {
dispatch(createAntennaRequest());
api(getState).post('/api/v1/antennas', { title }).then(({ data }) => {
dispatch(createAntennaSuccess(data));
if (shouldReset) {
dispatch(resetAntennaEditor());
}
}).catch(err => dispatch(createAntennaFail(err)));
};
export const createAntennaRequest = () => ({
type: ANTENNA_CREATE_REQUEST,
});
export const createAntennaSuccess = antenna => ({
type: ANTENNA_CREATE_SUCCESS,
antenna,
});
export const createAntennaFail = error => ({
type: ANTENNA_CREATE_FAIL,
error,
});
export const updateAntenna = (id, title, shouldReset, stl, with_media_only, ignore_reblog) => (dispatch, getState) => {
dispatch(updateAntennaRequest(id));
api(getState).put(`/api/v1/antennas/${id}`, { title, stl, with_media_only, ignore_reblog }).then(({ data }) => {
dispatch(updateAntennaSuccess(data));
if (shouldReset) {
dispatch(resetAntennaEditor());
}
}).catch(err => dispatch(updateAntennaFail(id, err)));
};
export const updateAntennaRequest = id => ({
type: ANTENNA_UPDATE_REQUEST,
id,
});
export const updateAntennaSuccess = antenna => ({
type: ANTENNA_UPDATE_SUCCESS,
antenna,
});
export const updateAntennaFail = (id, error) => ({
type: ANTENNA_UPDATE_FAIL,
id,
error,
});
export const resetAntennaEditor = () => ({
type: ANTENNA_EDITOR_RESET,
});
export const deleteAntenna = id => (dispatch, getState) => {
dispatch(deleteAntennaRequest(id));
api(getState).delete(`/api/v1/antennas/${id}`)
.then(() => dispatch(deleteAntennaSuccess(id)))
.catch(err => dispatch(deleteAntennaFail(id, err)));
};
export const deleteAntennaRequest = id => ({
type: ANTENNA_DELETE_REQUEST,
id,
});
export const deleteAntennaSuccess = id => ({
type: ANTENNA_DELETE_SUCCESS,
id,
});
export const deleteAntennaFail = (id, error) => ({
type: ANTENNA_DELETE_FAIL,
id,
error,
});
export const fetchAntennaAccounts = antennaId => (dispatch, getState) => { export const fetchAntennaAccounts = antennaId => (dispatch, getState) => {
dispatch(fetchAntennaAccountsRequest(antennaId)); dispatch(fetchAntennaAccountsRequest(antennaId));
@ -74,70 +234,106 @@ export const fetchAntennaAccountsFail = (id, error) => ({
error, error,
}); });
export const addAccountToAntenna = (antennaId, accountId) => (dispatch, getState) => { export const fetchAntennaSuggestions = q => (dispatch, getState) => {
dispatch(addAccountToAntennaRequest(antennaId, accountId)); const params = {
q,
resolve: false,
};
api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchAntennaSuggestionsReady(q, data));
}).catch(error => dispatch(showAlertForError(error)));
};
export const fetchAntennaSuggestionsReady = (query, accounts) => ({
type: ANTENNA_EDITOR_SUGGESTIONS_READY,
query,
accounts,
});
export const clearAntennaSuggestions = () => ({
type: ANTENNA_EDITOR_SUGGESTIONS_CLEAR,
});
export const changeAntennaSuggestions = value => ({
type: ANTENNA_EDITOR_SUGGESTIONS_CHANGE,
value,
});
export const addToAntennaEditor = accountId => (dispatch, getState) => {
dispatch(addToAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId));
};
export const addToAntenna = (antennaId, accountId) => (dispatch, getState) => {
dispatch(addToAntennaRequest(antennaId, accountId));
api(getState).post(`/api/v1/antennas/${antennaId}/accounts`, { account_ids: [accountId] }) api(getState).post(`/api/v1/antennas/${antennaId}/accounts`, { account_ids: [accountId] })
.then(() => dispatch(addAccountToAntennaSuccess(antennaId, accountId))) .then(() => dispatch(addToAntennaSuccess(antennaId, accountId)))
.catch(err => dispatch(addAccountToAntennaFail(antennaId, accountId, err))); .catch(err => dispatch(addToAntennaFail(antennaId, accountId, err)));
}; };
export const addAccountToAntennaRequest = (antennaId, accountId) => ({ export const addToAntennaRequest = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_ADD_ACCOUNT_REQUEST, type: ANTENNA_EDITOR_ADD_REQUEST,
antennaId, antennaId,
accountId, accountId,
}); });
export const addAccountToAntennaSuccess = (antennaId, accountId) => ({ export const addToAntennaSuccess = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS, type: ANTENNA_EDITOR_ADD_SUCCESS,
antennaId, antennaId,
accountId, accountId,
}); });
export const addAccountToAntennaFail = (antennaId, accountId, error) => ({ export const addToAntennaFail = (antennaId, accountId, error) => ({
type: ANTENNA_EDITOR_ADD_ACCOUNT_FAIL, type: ANTENNA_EDITOR_ADD_FAIL,
antennaId, antennaId,
accountId, accountId,
error, error,
}); });
export const removeAccountFromAntennaEditor = accountId => (dispatch, getState) => { export const removeFromAntennaEditor = accountId => (dispatch, getState) => {
dispatch(removeAccountFromAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId)); dispatch(removeFromAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId));
}; };
export const removeAccountFromAntenna = (antennaId, accountId) => (dispatch, getState) => { export const removeFromAntenna = (antennaId, accountId) => (dispatch, getState) => {
dispatch(removeAccountFromAntennaRequest(antennaId, accountId)); dispatch(removeFromAntennaRequest(antennaId, accountId));
api(getState).delete(`/api/v1/antennas/${antennaId}/accounts`, { params: { account_ids: [accountId] } }) api(getState).delete(`/api/v1/antennas/${antennaId}/accounts`, { params: { account_ids: [accountId] } })
.then(() => dispatch(removeAccountFromAntennaSuccess(antennaId, accountId))) .then(() => dispatch(removeFromAntennaSuccess(antennaId, accountId)))
.catch(err => dispatch(removeAccountFromAntennaFail(antennaId, accountId, err))); .catch(err => dispatch(removeFromAntennaFail(antennaId, accountId, err)));
}; };
export const removeAccountFromAntennaRequest = (antennaId, accountId) => ({ export const removeFromAntennaRequest = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_REMOVE_ACCOUNT_REQUEST, type: ANTENNA_EDITOR_REMOVE_REQUEST,
antennaId, antennaId,
accountId, accountId,
}); });
export const removeAccountFromAntennaSuccess = (antennaId, accountId) => ({ export const removeFromAntennaSuccess = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS, type: ANTENNA_EDITOR_REMOVE_SUCCESS,
antennaId, antennaId,
accountId, accountId,
}); });
export const removeAccountFromAntennaFail = (antennaId, accountId, error) => ({ export const removeFromAntennaFail = (antennaId, accountId, error) => ({
type: ANTENNA_EDITOR_REMOVE_ACCOUNT_FAIL, type: ANTENNA_EDITOR_REMOVE_FAIL,
antennaId, antennaId,
accountId, accountId,
error, error,
}); });
export const addToAntennaAdder = antennaId => (dispatch, getState) => { export const resetAntennaAdder = () => ({
dispatch(addAccountToAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId']))); type: ANTENNA_ADDER_RESET,
}; });
export const removeFromAntennaAdder = antennaId => (dispatch, getState) => { export const setupAntennaAdder = accountId => (dispatch, getState) => {
dispatch(removeAccountFromAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId']))); dispatch({
type: ANTENNA_ADDER_SETUP,
account: getState().getIn(['accounts', accountId]),
});
dispatch(fetchAntennas());
dispatch(fetchAccountAntennas(accountId));
}; };
export const fetchAccountAntennas = accountId => (dispatch, getState) => { export const fetchAccountAntennas = accountId => (dispatch, getState) => {
@ -165,16 +361,11 @@ export const fetchAccountAntennasFail = (id, err) => ({
err, err,
}); });
export const resetAntennaAdder = () => ({ export const addToAntennaAdder = antennaId => (dispatch, getState) => {
type: ANTENNA_ADDER_RESET, dispatch(addToAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
}); };
export const setupAntennaAdder = accountId => (dispatch, getState) => { export const removeFromAntennaAdder = antennaId => (dispatch, getState) => {
dispatch({ dispatch(removeFromAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
type: ANTENNA_ADDER_SETUP,
account: getState().getIn(['accounts', accountId]),
});
dispatch(fetchAntennas());
dispatch(fetchAccountAntennas(accountId));
}; };

View file

@ -238,7 +238,6 @@ export const fetchListSuggestions = q => (dispatch, getState) => {
const params = { const params = {
q, q,
resolve: false, resolve: false,
limit: 4,
following: true, following: true,
}; };

View file

@ -1,43 +0,0 @@
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { makeGetAccount } from '../../../selectors';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, accountId),
});
return mapStateToProps;
};
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
const { account } = this.props;
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps)(injectIntl(Account));

View file

@ -12,8 +12,8 @@ import { removeFromAntennaAdder, addToAntennaAdder } from '../../../actions/ante
import { IconButton } from '../../../components/icon_button'; import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({ const messages = defineMessages({
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' }, remove: { id: 'antennas.account.remove', defaultMessage: 'Remove from antenna' },
add: { id: 'lists.account.add', defaultMessage: 'Add to list' }, add: { id: 'antennas.account.add', defaultMessage: 'Add to antenna' },
}); });
const MapStateToProps = (state, { antennaId, added }) => ({ const MapStateToProps = (state, { antennaId, added }) => ({

View file

@ -8,8 +8,9 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { setupAntennaAdder, resetAntennaAdder } from '../../actions/antennas'; import { setupAntennaAdder, resetAntennaAdder } from '../../actions/antennas';
import NewAntennaForm from '../antennas/components/new_antenna_form';
import Account from '../list_adder/components/account';
import Account from './components/account';
import Antenna from './components/antenna'; import Antenna from './components/antenna';
// hack // hack
@ -60,8 +61,11 @@ class AntennaAdder extends ImmutablePureComponent {
<Account accountId={accountId} /> <Account accountId={accountId} />
</div> </div>
<NewAntennaForm />
<div className='list-adder__lists'> <div className='list-adder__lists'>
{antennaIds.map(AntennaId => <Antenna key={AntennaId} antennaId={AntennaId} />)} {antennaIds.map(antennaId => <Antenna key={antennaId} antennaId={antennaId} />)}
</div> </div>
</div> </div>
); );

View file

@ -0,0 +1,79 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { removeFromAntennaEditor, addToAntennaEditor } from '../../../actions/antennas';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { IconButton } from '../../../components/icon_button';
import { makeGetAccount } from '../../../selectors';
const messages = defineMessages({
remove: { id: 'antennas.account.remove', defaultMessage: 'Remove from antenna' },
add: { id: 'antennas.account.add', defaultMessage: 'Add to antenna' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added }) => ({
account: getAccount(state, accountId),
added: typeof added === 'undefined' ? state.getIn(['antennaEditor', 'accounts', 'items']).includes(accountId) : added,
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { accountId }) => ({
onRemove: () => dispatch(removeFromAntennaEditor(accountId)),
onAdd: () => dispatch(addToAntennaEditor(accountId)),
});
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { account, intl, onRemove, onAdd, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />;
}
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(Account));

View file

@ -0,0 +1,73 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { changeAntennaEditorTitle, submitAntennaEditor } from '../../../actions/antennas';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
title: { id: 'antennas.edit.submit', defaultMessage: 'Change title' },
});
const mapStateToProps = state => ({
value: state.getIn(['antennaEditor', 'title']),
disabled: !state.getIn(['antennaEditor', 'isChanged']) || !state.getIn(['antennaEditor', 'title']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeAntennaEditorTitle(value)),
onSubmit: () => dispatch(submitAntennaEditor(false)),
});
class AntennaForm extends PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
};
handleClick = () => {
this.props.onSubmit();
};
render () {
const { value, disabled, intl } = this.props;
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<input
className='setting-text'
value={value}
onChange={this.handleChange}
/>
<IconButton
disabled={disabled}
icon='check'
title={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(AntennaForm));

View file

@ -0,0 +1,81 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { connect } from 'react-redux';
import { Icon } from 'mastodon/components/icon';
import { fetchAntennaSuggestions, clearAntennaSuggestions, changeAntennaSuggestions } from '../../../actions/antennas';
const messages = defineMessages({
search: { id: 'antennas.search', defaultMessage: 'Search among people you follow' },
});
const mapStateToProps = state => ({
value: state.getIn(['antennaEditor', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchAntennaSuggestions(value)),
onClear: () => dispatch(clearAntennaSuggestions()),
onChange: value => dispatch(changeAntennaSuggestions(value)),
});
class Search extends PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleKeyUp = e => {
if (e.keyCode === 13) {
this.props.onSubmit(this.props.value);
}
};
handleClear = () => {
this.props.onClear();
};
render () {
const { value, intl } = this.props;
const hasValue = value.length > 0;
return (
<div className='list-editor__search search'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
<input
className='search__input'
type='text'
value={value}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}
placeholder={intl.formatMessage(messages.search)}
/>
</label>
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
<Icon id='search' className={classNames({ active: !hasValue })} />
<Icon id='times-circle' aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Search));

View file

@ -0,0 +1,83 @@
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import spring from 'react-motion/lib/spring';
import { setupAntennaEditor, clearAntennaSuggestions, resetAntennaEditor } from '../../actions/antennas';
import Motion from '../ui/util/optional_motion';
import Account from './components/account';
import EditAntennaForm from './components/edit_antenna_form';
import Search from './components/search';
const mapStateToProps = state => ({
accountIds: state.getIn(['antennaEditor', 'accounts', 'items']),
searchAccountIds: state.getIn(['antennaEditor', 'suggestions', 'items']),
});
const mapDispatchToProps = dispatch => ({
onInitialize: antennaId => dispatch(setupAntennaEditor(antennaId)),
onClear: () => dispatch(clearAntennaSuggestions()),
onReset: () => dispatch(resetAntennaEditor()),
});
class AntennaEditor extends ImmutablePureComponent {
static propTypes = {
antennaId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list.isRequired,
searchAccountIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, antennaId } = this.props;
onInitialize(antennaId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountIds, searchAccountIds, onClear } = this.props;
const showSearch = searchAccountIds.size > 0;
return (
<div className='modal-root__modal list-editor'>
<EditAntennaForm />
<Search />
<div className='drawer__pager'>
<div className='drawer__inner list-editor__accounts'>
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} added />)}
</div>
{showSearch && <div role='button' tabIndex={-1} className='drawer__backdrop' onClick={onClear} />}
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) => (
<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
</div>
)}
</Motion>
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(AntennaEditor));

View file

@ -0,0 +1,256 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import Toggle from 'react-toggle';
import { fetchAntenna, deleteAntenna, updateAntenna } from 'mastodon/actions/antennas';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { openModal } from 'mastodon/actions/modal';
import Button from 'mastodon/components/button';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
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' },
});
const mapStateToProps = (state, props) => ({
antenna: state.getIn(['antennas', props.params.id]),
});
class AntennaSetting extends PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
antenna: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
intl: PropTypes.object.isRequired,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('ANTENNA', { id: this.props.params.id }));
this.context.router.history.push('/');
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
componentDidMount () {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(fetchAntenna(id));
}
UNSAFE_componentWillReceiveProps (nextProps) {
const { dispatch } = this.props;
const { id } = nextProps.params;
if (id !== this.props.params.id) {
dispatch(fetchAntenna(id));
}
}
setRef = c => {
this.column = c;
};
handleEditClick = () => {
this.props.dispatch(openModal({
modalType: 'ANTENNA_EDITOR',
modalProps: { antennaId: this.props.params.id },
}));
};
handleEditAntennaClick = () => {
window.open(`/antennas/${this.props.params.id}/edit`, '_blank');
}
handleDeleteClick = () => {
const { dispatch, columnId, intl } = this.props;
const { id } = this.props.params;
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
dispatch(deleteAntenna(id));
if (columnId) {
dispatch(removeColumn(columnId));
} else {
this.context.router.history.push('/antennasw');
}
},
},
}));
};
onStlToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateAntenna(id, undefined, false, target.checked, undefined, undefined));
};
onMediaOnlyToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateAntenna(id, undefined, false, undefined, target.checked, undefined));
};
onIgnoreReblogToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateAntenna(id, undefined, false, undefined, undefined, target.checked));
};
render () {
const { columnId, multiColumn, antenna, intl } = this.props;
const { id } = this.props.params;
const pinned = !!columnId;
const title = antenna ? antenna.get('title') : id;
const isStl = antenna ? antenna.get('stl') : undefined;
const isMediaOnly = antenna ? antenna.get('with_media_only') : undefined;
const isIgnoreReblog = antenna ? antenna.get('ignore_reblog') : undefined;
if (typeof antenna === 'undefined') {
return (
<Column>
<div className='scrollable'>
<LoadingIndicator />
</div>
</Column>
);
} else if (antenna === false) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
let columnSettings;
if (!isStl) {
columnSettings = (
<>
<div className='setting-toggle'>
<Toggle id={`antenna-${id}-mediaonly`} defaultChecked={isMediaOnly} onChange={this.onMediaOnlyToggle} />
<label htmlFor={`antenna-${id}-mediaonly`} className='setting-toggle__label'>
<FormattedMessage id='antennas.media_only' defaultMessage='Media only' />
</label>
</div>
<div className='setting-toggle'>
<Toggle id={`antenna-${id}-ignorereblog`} defaultChecked={isIgnoreReblog} onChange={this.onIgnoreReblogToggle} />
<label htmlFor={`antenna-${id}-ignorereblog`} className='setting-toggle__label'>
<FormattedMessage id='antennas.ignore_reblog' defaultMessage='Exclude boosts' />
</label>
</div>
</>
)
}
let stlAlert;
if (isStl) {
stlAlert = (
<div class='antenna-setting'>
<p><FormattedMessage id='antennas.in_stl_mode' defaultMessage='This antenna is in STL mode.' /></p>
</div>
);
}
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={title}>
<ColumnHeader
icon='wifi'
title={title}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<div className='column-settings__row column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditAntennaClick}>
<Icon id='pencil' /> <FormattedMessage id='anntennas.edit' defaultMessage='Edit antenna' />
</button>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' /> <FormattedMessage id='antennas.delete' defaultMessage='Delete antenna' />
</button>
</div>
<div className='setting-toggle'>
<Toggle id={`antenna-${id}-stl`} defaultChecked={isStl} onChange={this.onStlToggle} />
<label htmlFor={`antenna-${id}-stl`} className='setting-toggle__label'>
<FormattedMessage id='antennas.stl' defaultMessage='STL mode' />
</label>
</div>
{columnSettings}
</ColumnHeader>
{stlAlert}
{!isStl && (
<div class='antenna-setting'>
{antenna.get('list') ? (
<p><FormattedMessage id='antennas.related_list' defaultMessage='This antenna is related to {listTitle}.' values={{ listTitle: antenna.getIn(['list', 'title']) }} /></p>
) : (
<>
<p><FormattedMessage id='antennas.not_related_list' defaultMessage='This antenna is not related list. Posts will appear in home timeline. Open edit page to set list.' /></p>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditAntennaClick}>
<Icon id='pencil' /> <FormattedMessage id='anntennas.edit' defaultMessage='Edit antenna' />
</button>
</>
)}
<h3><FormattedMessage id='antennas.accounts' defaultMessage='{count} accounts' values={{ count: antenna.get('accounts_count') }} /></h3>
<Button text={intl.formatMessage(messages.editAccounts)} onClick={this.handleEditClick} />
<h3><FormattedMessage id='antennas.domains' defaultMessage='{count} domains' values={{ count: antenna.get('domains_count') }} /></h3>
<h3><FormattedMessage id='antennas.tags' defaultMessage='{count} tags' values={{ count: antenna.get('tags_count') }} /></h3>
<h3><FormattedMessage id='antennas.keywords' defaultMessage='{count} keywords' values={{ count: antenna.get('keywords_count') }} /></h3>
</div>
)}
<Helmet>
<title>{title}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(AntennaSetting));

View file

@ -0,0 +1,80 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { changeAntennaEditorTitle, submitAntennaEditor } from 'mastodon/actions/antennas';
import Button from 'mastodon/components/button';
const messages = defineMessages({
label: { id: 'antennas.new.title_placeholder', defaultMessage: 'New antenna title' },
title: { id: 'antennas.new.create', defaultMessage: 'Add antenna' },
});
const mapStateToProps = state => ({
value: state.getIn(['antennaEditor', 'title']),
disabled: state.getIn(['antennaEditor', 'isSubmitting']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeAntennaEditorTitle(value)),
onSubmit: () => dispatch(submitAntennaEditor(true)),
});
class NewAntennaForm extends PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
};
handleClick = () => {
this.props.onSubmit();
};
render () {
const { value, disabled, intl } = this.props;
const label = intl.formatMessage(messages.label);
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
value={value}
disabled={disabled}
onChange={this.handleChange}
placeholder={label}
/>
</label>
<Button
disabled={disabled || !value}
text={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(NewAntennaForm));

View file

@ -0,0 +1,93 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchAntennas } from 'mastodon/actions/antennas';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import ColumnLink from 'mastodon/features/ui/components/column_link';
import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';
import NewAntennaForm from './components/new_antenna_form';
const messages = defineMessages({
heading: { id: 'column.antennas', defaultMessage: 'Antennas' },
subheading: { id: 'antennas.subheading', defaultMessage: 'Your antennas' },
});
const getOrderedAntennas = createSelector([state => state.get('antennas')], antennas => {
if (!antennas) {
return antennas;
}
return antennas.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
antennas: getOrderedAntennas(state),
});
class Antennas extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
antennas: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchAntennas());
}
render () {
const { intl, antennas, multiColumn } = this.props;
if (!antennas) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.antennas' defaultMessage="You don't have any antennas yet. When you create one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.heading)}>
<ColumnHeader title={intl.formatMessage(messages.heading)} icon='wifi' multiColumn={multiColumn} />
<NewAntennaForm />
<ScrollableList
scrollKey='antennas'
emptyMessage={emptyMessage}
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
bindToDocument={!multiColumn}
>
{antennas.map(antenna =>
<ColumnLink key={antenna.get('id')} to={`/antennasw/${antenna.get('id')}`} icon='wifi' text={antenna.get('title')} />,
)}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Antennas));

View file

@ -11,6 +11,7 @@ import {
EmbedModal, EmbedModal,
ListEditor, ListEditor,
ListAdder, ListAdder,
AntennaEditor,
AntennaAdder, AntennaAdder,
CompareHistoryModal, CompareHistoryModal,
FilterModal, FilterModal,
@ -46,6 +47,7 @@ export const MODAL_COMPONENTS = {
'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal, 'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor, 'LIST_EDITOR': ListEditor,
'ANTENNA_EDITOR': AntennaEditor,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'LIST_ADDER': ListAdder, 'LIST_ADDER': ListAdder,
'ANTENNA_ADDER': AntennaAdder, 'ANTENNA_ADDER': AntennaAdder,

View file

@ -27,6 +27,7 @@ const messages = defineMessages({
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
antennas: { id: 'navigation_bar.antennas', defaultMessage: 'Antennas' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' }, followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' },
about: { id: 'navigation_bar.about', defaultMessage: 'About' }, about: { id: 'navigation_bar.about', defaultMessage: 'About' },
@ -109,6 +110,7 @@ class NavigationPanel extends Component {
{signedIn && ( {signedIn && (
<> <>
<ColumnLink transparent to='/antennasw' icon='wifi' text={intl.formatMessage(messages.antennas)} />
<ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} /> <ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} />
<ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} /> <ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} />
<hr /> <hr />

View file

@ -60,6 +60,8 @@ import {
Mutes, Mutes,
PinnedStatuses, PinnedStatuses,
Lists, Lists,
Antennas,
AntennaSetting,
Directory, Directory,
Explore, Explore,
ReactionDeck, ReactionDeck,
@ -207,6 +209,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} /> <WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} /> <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} /> <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
<WrappedRoute path='/antennasw/:id' component={AntennaSetting} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} /> <WrappedRoute path='/notifications' component={Notifications} content={children} />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/emoji_reactions' component={EmojiReactedStatuses} content={children} /> <WrappedRoute path='/emoji_reactions' component={EmojiReactedStatuses} content={children} />
@ -248,6 +251,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} /> <WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} /> <WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} /> <WrappedRoute path='/lists' component={Lists} content={children} />
<WrappedRoute path='/antennasw' component={Antennas} content={children} />
<Route component={BundleColumnError} /> <Route component={BundleColumnError} />
</WrappedSwitch> </WrappedSwitch>

View file

@ -42,6 +42,10 @@ export function Lists () {
return import(/* webpackChunkName: "features/lists" */'../../lists'); return import(/* webpackChunkName: "features/lists" */'../../lists');
} }
export function Antennas () {
return import(/* webpackChunkName: "features/antennas" */'../../antennas');
}
export function Status () { export function Status () {
return import(/* webpackChunkName: "features/status" */'../../status'); return import(/* webpackChunkName: "features/status" */'../../status');
} }
@ -158,6 +162,14 @@ export function AntennaAdder () {
return import(/*webpackChunkName: "features/antenna_adder" */'../../antenna_adder'); return import(/*webpackChunkName: "features/antenna_adder" */'../../antenna_adder');
} }
export function AntennaEditor () {
return import(/*webpackChunkName: "features/antenna_editor" */'../../antenna_editor');
}
export function AntennaSetting () {
return import(/*webpackChunkName: "features/antenna_setting" */'../../antenna_setting');
}
export function Tesseract () { export function Tesseract () {
return import(/*webpackChunkName: "tesseract" */'tesseract.js'); return import(/*webpackChunkName: "tesseract" */'tesseract.js');
} }

View file

@ -6,8 +6,8 @@ import {
ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST, ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST,
ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS, ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS,
ANTENNA_ADDER_ANTENNAS_FETCH_FAIL, ANTENNA_ADDER_ANTENNAS_FETCH_FAIL,
ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS, ANTENNA_EDITOR_ADD_SUCCESS,
ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS, ANTENNA_EDITOR_REMOVE_SUCCESS,
} from '../actions/antennas'; } from '../actions/antennas';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
@ -38,9 +38,9 @@ export default function antennaAdderReducer(state = initialState, action) {
map.set('loaded', true); map.set('loaded', true);
map.set('items', ImmutableList(action.antennas.map(item => item.id))); map.set('items', ImmutableList(action.antennas.map(item => item.id)));
})); }));
case ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS: case ANTENNA_EDITOR_ADD_SUCCESS:
return state.updateIn(['antennas', 'items'], antenna => antenna.unshift(action.antennaId)); return state.updateIn(['antennas', 'items'], antenna => antenna.unshift(action.antennaId));
case ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS: case ANTENNA_EDITOR_REMOVE_SUCCESS:
return state.updateIn(['antennas', 'items'], antenna => antenna.filterNot(item => item === action.antennaId)); return state.updateIn(['antennas', 'items'], antenna => antenna.filterNot(item => item === action.antennaId));
default: default:
return state; return state;

View file

@ -1,9 +1,23 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { import {
ANTENNA_CREATE_REQUEST,
ANTENNA_CREATE_FAIL,
ANTENNA_CREATE_SUCCESS,
ANTENNA_UPDATE_REQUEST,
ANTENNA_UPDATE_FAIL,
ANTENNA_UPDATE_SUCCESS,
ANTENNA_EDITOR_RESET,
ANTENNA_EDITOR_SETUP,
ANTENNA_EDITOR_TITLE_CHANGE,
ANTENNA_ACCOUNTS_FETCH_REQUEST, ANTENNA_ACCOUNTS_FETCH_REQUEST,
ANTENNA_ACCOUNTS_FETCH_SUCCESS, ANTENNA_ACCOUNTS_FETCH_SUCCESS,
ANTENNA_ACCOUNTS_FETCH_FAIL, ANTENNA_ACCOUNTS_FETCH_FAIL,
ANTENNA_EDITOR_SUGGESTIONS_READY,
ANTENNA_EDITOR_SUGGESTIONS_CLEAR,
ANTENNA_EDITOR_SUGGESTIONS_CHANGE,
ANTENNA_EDITOR_ADD_SUCCESS,
ANTENNA_EDITOR_REMOVE_SUCCESS,
} from '../actions/antennas'; } from '../actions/antennas';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
@ -11,16 +25,51 @@ const initialState = ImmutableMap({
isSubmitting: false, isSubmitting: false,
isChanged: false, isChanged: false,
title: '', title: '',
accountsCount: 0,
accounts: ImmutableMap({ accounts: ImmutableMap({
items: ImmutableList(), items: ImmutableList(),
loaded: false, loaded: false,
isLoading: false, isLoading: false,
}), }),
suggestions: ImmutableMap({
value: '',
items: ImmutableList(),
}),
}); });
export default function antennaEditorReducer(state = initialState, action) { export default function antennaEditorReducer(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ANTENNA_EDITOR_RESET:
return initialState;
case ANTENNA_EDITOR_SETUP:
return state.withMutations(map => {
map.set('antennaId', action.antenna.get('id'));
map.set('title', action.antenna.get('title'));
map.set('accountsCount', action.antenna.get('accounts_count'));
map.set('isSubmitting', false);
});
case ANTENNA_EDITOR_TITLE_CHANGE:
return state.withMutations(map => {
map.set('title', action.value);
map.set('isChanged', true);
});
case ANTENNA_CREATE_REQUEST:
case ANTENNA_UPDATE_REQUEST:
return state.withMutations(map => {
map.set('isSubmitting', true);
map.set('isChanged', false);
});
case ANTENNA_CREATE_FAIL:
case ANTENNA_UPDATE_FAIL:
return state.set('isSubmitting', false);
case ANTENNA_CREATE_SUCCESS:
case ANTENNA_UPDATE_SUCCESS:
return state.withMutations(map => {
map.set('isSubmitting', false);
map.set('antennaId', action.antenna.id);
});
case ANTENNA_ACCOUNTS_FETCH_REQUEST: case ANTENNA_ACCOUNTS_FETCH_REQUEST:
return state.setIn(['accounts', 'isLoading'], true); return state.setIn(['accounts', 'isLoading'], true);
case ANTENNA_ACCOUNTS_FETCH_FAIL: case ANTENNA_ACCOUNTS_FETCH_FAIL:
@ -31,6 +80,19 @@ export default function antennaEditorReducer(state = initialState, action) {
map.set('loaded', true); map.set('loaded', true);
map.set('items', ImmutableList(action.accounts.map(item => item.id))); map.set('items', ImmutableList(action.accounts.map(item => item.id)));
})); }));
case ANTENNA_EDITOR_SUGGESTIONS_CHANGE:
return state.setIn(['suggestions', 'value'], action.value);
case ANTENNA_EDITOR_SUGGESTIONS_READY:
return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id)));
case ANTENNA_EDITOR_SUGGESTIONS_CLEAR:
return state.update('suggestions', suggestions => suggestions.withMutations(map => {
map.set('items', ImmutableList());
map.set('value', '');
}));
case ANTENNA_EDITOR_ADD_SUCCESS:
return state.updateIn(['accounts', 'items'], antenna => antenna.unshift(action.accountId));
case ANTENNA_EDITOR_REMOVE_SUCCESS:
return state.updateIn(['accounts', 'items'], antenna => antenna.filterNot(item => item === action.accountId));
default: default:
return state; return state;
} }

View file

@ -1,7 +1,14 @@
import { Map as ImmutableMap, fromJS } from 'immutable'; import { Map as ImmutableMap, fromJS } from 'immutable';
import { import {
ANTENNA_FETCH_SUCCESS,
ANTENNA_FETCH_FAIL,
ANTENNAS_FETCH_SUCCESS, ANTENNAS_FETCH_SUCCESS,
ANTENNA_CREATE_SUCCESS,
ANTENNA_UPDATE_SUCCESS,
ANTENNA_DELETE_SUCCESS,
ANTENNA_EDITOR_ADD_SUCCESS,
ANTENNA_EDITOR_REMOVE_SUCCESS,
} from '../actions/antennas'; } from '../actions/antennas';
const initialState = ImmutableMap(); const initialState = ImmutableMap();
@ -18,8 +25,19 @@ const normalizeAntennas = (state, antennas) => {
export default function antennas(state = initialState, action) { export default function antennas(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ANTENNA_FETCH_SUCCESS:
case ANTENNA_CREATE_SUCCESS:
case ANTENNA_UPDATE_SUCCESS:
return normalizeAntenna(state, action.antenna);
case ANTENNAS_FETCH_SUCCESS: case ANTENNAS_FETCH_SUCCESS:
return normalizeAntennas(state, action.antennas); return normalizeAntennas(state, action.antennas);
case ANTENNA_DELETE_SUCCESS:
case ANTENNA_FETCH_FAIL:
return state.set(action.id, false);
case ANTENNA_EDITOR_ADD_SUCCESS:
return state.setIn([action.antennaId, 'accounts_count'], state.getIn([action.antennaId, 'accounts_count']) + 1);
case ANTENNA_EDITOR_REMOVE_SUCCESS:
return state.setIn([action.antennaId, 'accounts_count'], state.getIn([action.antennaId, 'accounts_count']) - 1);
default: default:
return state; return state;
} }

View file

@ -7364,6 +7364,15 @@ noscript {
} }
} }
.antenna-setting {
margin: 8px 16px;
h3 {
font-size: 16px;
margin: 24px 0 8px;
}
}
.reaction_deck_container { .reaction_deck_container {
&__row { &__row {
display: flex; display: flex;

View file

@ -33,6 +33,7 @@ class Antenna < ApplicationRecord
has_many :antenna_domains, inverse_of: :antenna, dependent: :destroy has_many :antenna_domains, inverse_of: :antenna, dependent: :destroy
has_many :antenna_tags, inverse_of: :antenna, dependent: :destroy has_many :antenna_tags, inverse_of: :antenna, dependent: :destroy
has_many :antenna_accounts, inverse_of: :antenna, dependent: :destroy has_many :antenna_accounts, inverse_of: :antenna, dependent: :destroy
has_many :accounts, through: :antenna_accounts
belongs_to :account belongs_to :account
belongs_to :list, optional: true belongs_to :list, optional: true

View file

@ -1,9 +1,35 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::AntennaSerializer < ActiveModel::Serializer class REST::AntennaSerializer < ActiveModel::Serializer
attributes :id, :title, :stl, :with_media_only, :ignore_reblog, :accounts_count, :domains_count, :tags_count, :keywords_count
class ListSerializer < ActiveModel::Serializer
attributes :id, :title attributes :id, :title
def id def id
object.id.to_s object.id.to_s
end end
end end
has_one :list, serializer: ListSerializer, optional: true
def id
object.id.to_s
end
def accounts_count
object.antenna_accounts.count
end
def domains_count
object.antenna_domains.count
end
def tags_count
object.antenna_tags.count
end
def keywords_count
object.keywords&.size || 0
end
end

View file

@ -16,6 +16,7 @@ Rails.application.routes.draw do
/public/remote /public/remote
/conversations /conversations
/lists/(*any) /lists/(*any)
/antennasw/(*any)
/notifications /notifications
/favourites /favourites
/emoji_reactions /emoji_reactions