diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index 3061fcb7e7..0ac7858e16 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -18,6 +18,7 @@ class Api::V1::Accounts::SearchController < Api::BaseController limit: limit_param(DEFAULT_ACCOUNTS_LIMIT), resolve: truthy_param?(:resolve), following: truthy_param?(:following), + follower: truthy_param?(:follower), offset: params[:offset] ) end diff --git a/app/controllers/api/v1/circles/accounts_controller.rb b/app/controllers/api/v1/circles/accounts_controller.rb new file mode 100644 index 0000000000..e0d43bd950 --- /dev/null +++ b/app/controllers/api/v1/circles/accounts_controller.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class Api::V1::Circles::AccountsController < 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_circle + + after_action :insert_pagination_headers, only: :show + + def show + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + def create + ApplicationRecord.transaction do + circle_accounts.each do |account| + @circle.accounts << account + end + end + + render_empty + end + + def destroy + CircleAccount.where(circle: @circle, account_id: account_ids).destroy_all + render_empty + end + + private + + def set_circle + @circle = Circle.where(account: current_account).find(params[:circle_id]) + end + + def load_accounts + if unlimited? + @circle.accounts.without_suspended.includes(:account_stat).all + else + @circle.accounts.without_suspended.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + end + end + + def circle_accounts + Account.find(account_ids) + end + + def account_ids + Array(resource_params[:account_ids]) + end + + def resource_params + params.permit(account_ids: []) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + api_v1_circle_accounts_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + return if unlimited? + + api_v1_circle_accounts_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? + end + + def pagination_max_id + @accounts.last.id + end + + def pagination_since_id + @accounts.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def unlimited? + params[:limit] == '0' + end +end diff --git a/app/controllers/api/v1/circles_controller.rb b/app/controllers/api/v1/circles_controller.rb new file mode 100644 index 0000000000..53c9adf14e --- /dev/null +++ b/app/controllers/api/v1/circles_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Api::V1::CirclesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show] + + before_action :require_user! + before_action :set_circle, except: [:index, :create] + + rescue_from ArgumentError do |e| + render json: { error: e.to_s }, status: 422 + end + + def index + @circles = Circle.where(account: current_account).all + render json: @circles, each_serializer: REST::CircleSerializer + end + + def show + render json: @circle, serializer: REST::CircleSerializer + end + + def create + @circle = Circle.create!(circle_params.merge(account: current_account)) + render json: @circle, serializer: REST::CircleSerializer + end + + def update + @circle.update!(circle_params) + render json: @circle, serializer: REST::CircleSerializer + end + + def destroy + @circle.destroy! + render_empty + end + + private + + def set_circle + @circle = Circle.where(account: current_account).find(params[:id]) + end + + def circle_params + params.permit(:title) + end +end diff --git a/app/javascript/mastodon/actions/circles.js b/app/javascript/mastodon/actions/circles.js new file mode 100644 index 0000000000..6a52e541c9 --- /dev/null +++ b/app/javascript/mastodon/actions/circles.js @@ -0,0 +1,372 @@ +import api from '../api'; + +import { showAlertForError } from './alerts'; +import { importFetchedAccounts } from './importer'; + +export const CIRCLE_FETCH_REQUEST = 'CIRCLE_FETCH_REQUEST'; +export const CIRCLE_FETCH_SUCCESS = 'CIRCLE_FETCH_SUCCESS'; +export const CIRCLE_FETCH_FAIL = 'CIRCLE_FETCH_FAIL'; + +export const CIRCLES_FETCH_REQUEST = 'CIRCLES_FETCH_REQUEST'; +export const CIRCLES_FETCH_SUCCESS = 'CIRCLES_FETCH_SUCCESS'; +export const CIRCLES_FETCH_FAIL = 'CIRCLES_FETCH_FAIL'; + +export const CIRCLE_EDITOR_TITLE_CHANGE = 'CIRCLE_EDITOR_TITLE_CHANGE'; +export const CIRCLE_EDITOR_RESET = 'CIRCLE_EDITOR_RESET'; +export const CIRCLE_EDITOR_SETUP = 'CIRCLE_EDITOR_SETUP'; + +export const CIRCLE_CREATE_REQUEST = 'CIRCLE_CREATE_REQUEST'; +export const CIRCLE_CREATE_SUCCESS = 'CIRCLE_CREATE_SUCCESS'; +export const CIRCLE_CREATE_FAIL = 'CIRCLE_CREATE_FAIL'; + +export const CIRCLE_UPDATE_REQUEST = 'CIRCLE_UPDATE_REQUEST'; +export const CIRCLE_UPDATE_SUCCESS = 'CIRCLE_UPDATE_SUCCESS'; +export const CIRCLE_UPDATE_FAIL = 'CIRCLE_UPDATE_FAIL'; + +export const CIRCLE_DELETE_REQUEST = 'CIRCLE_DELETE_REQUEST'; +export const CIRCLE_DELETE_SUCCESS = 'CIRCLE_DELETE_SUCCESS'; +export const CIRCLE_DELETE_FAIL = 'CIRCLE_DELETE_FAIL'; + +export const CIRCLE_ACCOUNTS_FETCH_REQUEST = 'CIRCLE_ACCOUNTS_FETCH_REQUEST'; +export const CIRCLE_ACCOUNTS_FETCH_SUCCESS = 'CIRCLE_ACCOUNTS_FETCH_SUCCESS'; +export const CIRCLE_ACCOUNTS_FETCH_FAIL = 'CIRCLE_ACCOUNTS_FETCH_FAIL'; + +export const CIRCLE_EDITOR_SUGGESTIONS_CHANGE = 'CIRCLE_EDITOR_SUGGESTIONS_CHANGE'; +export const CIRCLE_EDITOR_SUGGESTIONS_READY = 'CIRCLE_EDITOR_SUGGESTIONS_READY'; +export const CIRCLE_EDITOR_SUGGESTIONS_CLEAR = 'CIRCLE_EDITOR_SUGGESTIONS_CLEAR'; + +export const CIRCLE_EDITOR_ADD_REQUEST = 'CIRCLE_EDITOR_ADD_REQUEST'; +export const CIRCLE_EDITOR_ADD_SUCCESS = 'CIRCLE_EDITOR_ADD_SUCCESS'; +export const CIRCLE_EDITOR_ADD_FAIL = 'CIRCLE_EDITOR_ADD_FAIL'; + +export const CIRCLE_EDITOR_REMOVE_REQUEST = 'CIRCLE_EDITOR_REMOVE_REQUEST'; +export const CIRCLE_EDITOR_REMOVE_SUCCESS = 'CIRCLE_EDITOR_REMOVE_SUCCESS'; +export const CIRCLE_EDITOR_REMOVE_FAIL = 'CIRCLE_EDITOR_REMOVE_FAIL'; + +export const CIRCLE_ADDER_RESET = 'CIRCLE_ADDER_RESET'; +export const CIRCLE_ADDER_SETUP = 'CIRCLE_ADDER_SETUP'; + +export const CIRCLE_ADDER_CIRCLES_FETCH_REQUEST = 'CIRCLE_ADDER_CIRCLES_FETCH_REQUEST'; +export const CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS = 'CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS'; +export const CIRCLE_ADDER_CIRCLES_FETCH_FAIL = 'CIRCLE_ADDER_CIRCLES_FETCH_FAIL'; + +export const fetchCircle = id => (dispatch, getState) => { + if (getState().getIn(['circles', id])) { + return; + } + + dispatch(fetchCircleRequest(id)); + + api(getState).get(`/api/v1/circles/${id}`) + .then(({ data }) => dispatch(fetchCircleSuccess(data))) + .catch(err => dispatch(fetchCircleFail(id, err))); +}; + +export const fetchCircleRequest = id => ({ + type: CIRCLE_FETCH_REQUEST, + id, +}); + +export const fetchCircleSuccess = circle => ({ + type: CIRCLE_FETCH_SUCCESS, + circle, +}); + +export const fetchCircleFail = (id, error) => ({ + type: CIRCLE_FETCH_FAIL, + id, + error, +}); + +export const fetchCircles = () => (dispatch, getState) => { + dispatch(fetchCirclesRequest()); + + api(getState).get('/api/v1/circles') + .then(({ data }) => dispatch(fetchCirclesSuccess(data))) + .catch(err => dispatch(fetchCirclesFail(err))); +}; + +export const fetchCirclesRequest = () => ({ + type: CIRCLES_FETCH_REQUEST, +}); + +export const fetchCirclesSuccess = circles => ({ + type: CIRCLES_FETCH_SUCCESS, + circles, +}); + +export const fetchCirclesFail = error => ({ + type: CIRCLES_FETCH_FAIL, + error, +}); + +export const submitCircleEditor = shouldReset => (dispatch, getState) => { + const circleId = getState().getIn(['circleEditor', 'circleId']); + const title = getState().getIn(['circleEditor', 'title']); + + if (circleId === null) { + dispatch(createCircle(title, shouldReset)); + } else { + dispatch(updateCircle(circleId, title, shouldReset)); + } +}; + +export const setupCircleEditor = circleId => (dispatch, getState) => { + dispatch({ + type: CIRCLE_EDITOR_SETUP, + circle: getState().getIn(['circles', circleId]), + }); + + dispatch(fetchCircleAccounts(circleId)); +}; + +export const changeCircleEditorTitle = value => ({ + type: CIRCLE_EDITOR_TITLE_CHANGE, + value, +}); + +export const createCircle = (title, shouldReset) => (dispatch, getState) => { + dispatch(createCircleRequest()); + + api(getState).post('/api/v1/circles', { title }).then(({ data }) => { + dispatch(createCircleSuccess(data)); + + if (shouldReset) { + dispatch(resetCircleEditor()); + } + }).catch(err => dispatch(createCircleFail(err))); +}; + +export const createCircleRequest = () => ({ + type: CIRCLE_CREATE_REQUEST, +}); + +export const createCircleSuccess = circle => ({ + type: CIRCLE_CREATE_SUCCESS, + circle, +}); + +export const createCircleFail = error => ({ + type: CIRCLE_CREATE_FAIL, + error, +}); + +export const updateCircle = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => { + dispatch(updateCircleRequest(id)); + + api(getState).put(`/api/v1/circles/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => { + dispatch(updateCircleSuccess(data)); + + if (shouldReset) { + dispatch(resetCircleEditor()); + } + }).catch(err => dispatch(updateCircleFail(id, err))); +}; + +export const updateCircleRequest = id => ({ + type: CIRCLE_UPDATE_REQUEST, + id, +}); + +export const updateCircleSuccess = circle => ({ + type: CIRCLE_UPDATE_SUCCESS, + circle, +}); + +export const updateCircleFail = (id, error) => ({ + type: CIRCLE_UPDATE_FAIL, + id, + error, +}); + +export const resetCircleEditor = () => ({ + type: CIRCLE_EDITOR_RESET, +}); + +export const deleteCircle = id => (dispatch, getState) => { + dispatch(deleteCircleRequest(id)); + + api(getState).delete(`/api/v1/circles/${id}`) + .then(() => dispatch(deleteCircleSuccess(id))) + .catch(err => dispatch(deleteCircleFail(id, err))); +}; + +export const deleteCircleRequest = id => ({ + type: CIRCLE_DELETE_REQUEST, + id, +}); + +export const deleteCircleSuccess = id => ({ + type: CIRCLE_DELETE_SUCCESS, + id, +}); + +export const deleteCircleFail = (id, error) => ({ + type: CIRCLE_DELETE_FAIL, + id, + error, +}); + +export const fetchCircleAccounts = circleId => (dispatch, getState) => { + dispatch(fetchCircleAccountsRequest(circleId)); + + api(getState).get(`/api/v1/circles/${circleId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchCircleAccountsSuccess(circleId, data)); + }).catch(err => dispatch(fetchCircleAccountsFail(circleId, err))); +}; + +export const fetchCircleAccountsRequest = id => ({ + type: CIRCLE_ACCOUNTS_FETCH_REQUEST, + id, +}); + +export const fetchCircleAccountsSuccess = (id, accounts, next) => ({ + type: CIRCLE_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +export const fetchCircleAccountsFail = (id, error) => ({ + type: CIRCLE_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +export const fetchCircleSuggestions = q => (dispatch, getState) => { + const params = { + q, + resolve: false, + follower: true, + }; + + api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchCircleSuggestionsReady(q, data)); + }).catch(error => dispatch(showAlertForError(error))); +}; + +export const fetchCircleSuggestionsReady = (query, accounts) => ({ + type: CIRCLE_EDITOR_SUGGESTIONS_READY, + query, + accounts, +}); + +export const clearCircleSuggestions = () => ({ + type: CIRCLE_EDITOR_SUGGESTIONS_CLEAR, +}); + +export const changeCircleSuggestions = value => ({ + type: CIRCLE_EDITOR_SUGGESTIONS_CHANGE, + value, +}); + +export const addToCircleEditor = accountId => (dispatch, getState) => { + dispatch(addToCircle(getState().getIn(['circleEditor', 'circleId']), accountId)); +}; + +export const addToCircle = (circleId, accountId) => (dispatch, getState) => { + dispatch(addToCircleRequest(circleId, accountId)); + + api(getState).post(`/api/v1/circles/${circleId}/accounts`, { account_ids: [accountId] }) + .then(() => dispatch(addToCircleSuccess(circleId, accountId))) + .catch(err => dispatch(addToCircleFail(circleId, accountId, err))); +}; + +export const addToCircleRequest = (circleId, accountId) => ({ + type: CIRCLE_EDITOR_ADD_REQUEST, + circleId, + accountId, +}); + +export const addToCircleSuccess = (circleId, accountId) => ({ + type: CIRCLE_EDITOR_ADD_SUCCESS, + circleId, + accountId, +}); + +export const addToCircleFail = (circleId, accountId, error) => ({ + type: CIRCLE_EDITOR_ADD_FAIL, + circleId, + accountId, + error, +}); + +export const removeFromCircleEditor = accountId => (dispatch, getState) => { + dispatch(removeFromCircle(getState().getIn(['circleEditor', 'circleId']), accountId)); +}; + +export const removeFromCircle = (circleId, accountId) => (dispatch, getState) => { + dispatch(removeFromCircleRequest(circleId, accountId)); + + api(getState).delete(`/api/v1/circles/${circleId}/accounts`, { params: { account_ids: [accountId] } }) + .then(() => dispatch(removeFromCircleSuccess(circleId, accountId))) + .catch(err => dispatch(removeFromCircleFail(circleId, accountId, err))); +}; + +export const removeFromCircleRequest = (circleId, accountId) => ({ + type: CIRCLE_EDITOR_REMOVE_REQUEST, + circleId, + accountId, +}); + +export const removeFromCircleSuccess = (circleId, accountId) => ({ + type: CIRCLE_EDITOR_REMOVE_SUCCESS, + circleId, + accountId, +}); + +export const removeFromCircleFail = (circleId, accountId, error) => ({ + type: CIRCLE_EDITOR_REMOVE_FAIL, + circleId, + accountId, + error, +}); + +export const resetCircleAdder = () => ({ + type: CIRCLE_ADDER_RESET, +}); + +export const setupCircleAdder = accountId => (dispatch, getState) => { + dispatch({ + type: CIRCLE_ADDER_SETUP, + account: getState().getIn(['accounts', accountId]), + }); + dispatch(fetchCircles()); + dispatch(fetchAccountCircles(accountId)); +}; + +export const fetchAccountCircles = accountId => (dispatch, getState) => { + dispatch(fetchAccountCirclesRequest(accountId)); + + api(getState).get(`/api/v1/accounts/${accountId}/circles`) + .then(({ data }) => dispatch(fetchAccountCirclesSuccess(accountId, data))) + .catch(err => dispatch(fetchAccountCirclesFail(accountId, err))); +}; + +export const fetchAccountCirclesRequest = id => ({ + type:CIRCLE_ADDER_CIRCLES_FETCH_REQUEST, + id, +}); + +export const fetchAccountCirclesSuccess = (id, circles) => ({ + type: CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS, + id, + circles, +}); + +export const fetchAccountCirclesFail = (id, err) => ({ + type: CIRCLE_ADDER_CIRCLES_FETCH_FAIL, + id, + err, +}); + +export const addToCircleAdder = circleId => (dispatch, getState) => { + dispatch(addToCircle(circleId, getState().getIn(['circleAdder', 'accountId']))); +}; + +export const removeFromCircleAdder = circleId => (dispatch, getState) => { + dispatch(removeFromCircle(circleId, getState().getIn(['circleAdder', 'accountId']))); +}; + diff --git a/app/javascript/mastodon/components/icon_button.tsx b/app/javascript/mastodon/components/icon_button.tsx index 9dbee2cc24..ee63fba0e9 100644 --- a/app/javascript/mastodon/components/icon_button.tsx +++ b/app/javascript/mastodon/components/icon_button.tsx @@ -27,6 +27,7 @@ interface Props { obfuscateCount?: boolean; href?: string; ariaHidden: boolean; + data_id?: string; } interface States { activate: boolean; @@ -108,6 +109,7 @@ export class IconButton extends PureComponent { obfuscateCount, href, ariaHidden, + data_id, } = this.props; const { activate, deactivate } = this.state; @@ -160,6 +162,7 @@ export class IconButton extends PureComponent { style={style} tabIndex={tabIndex} disabled={disabled} + data-id={data_id} > {contents} diff --git a/app/javascript/mastodon/features/circle_adder/components/account.jsx b/app/javascript/mastodon/features/circle_adder/components/account.jsx new file mode 100644 index 0000000000..31a2e96379 --- /dev/null +++ b/app/javascript/mastodon/features/circle_adder/components/account.jsx @@ -0,0 +1,43 @@ +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 ( +
+
+
+
+ +
+
+
+ ); + } + +} + +export default connect(makeMapStateToProps)(injectIntl(Account)); diff --git a/app/javascript/mastodon/features/circle_adder/components/circle.jsx b/app/javascript/mastodon/features/circle_adder/components/circle.jsx new file mode 100644 index 0000000000..87ffb2ef16 --- /dev/null +++ b/app/javascript/mastodon/features/circle_adder/components/circle.jsx @@ -0,0 +1,72 @@ +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 { Icon } from 'mastodon/components/icon'; + +import { removeFromCircleAdder, addToCircleAdder } from '../../../actions/circles'; +import { IconButton } from '../../../components/icon_button'; + +const messages = defineMessages({ + remove: { id: 'circles.account.remove', defaultMessage: 'Remove from circle' }, + add: { id: 'circles.account.add', defaultMessage: 'Add to circle' }, +}); + +const MapStateToProps = (state, { circleId, added }) => ({ + circle: state.get('circles').get(circleId), + added: typeof added === 'undefined' ? state.getIn(['circleAdder', 'circles', 'items']).includes(circleId) : added, +}); + +const mapDispatchToProps = (dispatch, { circleId }) => ({ + onRemove: () => dispatch(removeFromCircleAdder(circleId)), + onAdd: () => dispatch(addToCircleAdder(circleId)), +}); + +class Circle extends ImmutablePureComponent { + + static propTypes = { + circle: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onRemove: PropTypes.func.isRequired, + onAdd: PropTypes.func.isRequired, + added: PropTypes.bool, + }; + + static defaultProps = { + added: false, + }; + + render () { + const { circle, intl, onRemove, onAdd, added } = this.props; + + let button; + + if (added) { + button = ; + } else { + button = ; + } + + return ( +
+
+
+ + {circle.get('title')} +
+ +
+ {button} +
+
+
+ ); + } + +} + +export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(Circle)); diff --git a/app/javascript/mastodon/features/circle_adder/index.jsx b/app/javascript/mastodon/features/circle_adder/index.jsx new file mode 100644 index 0000000000..9fb9d80a62 --- /dev/null +++ b/app/javascript/mastodon/features/circle_adder/index.jsx @@ -0,0 +1,76 @@ +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 { createSelector } from 'reselect'; + +import { setupCircleAdder, resetCircleAdder } from '../../actions/circles'; +import NewCircleForm from '../circles/components/new_circle_form'; + +import Account from './components/account'; +import Circle from './components/circle'; +// hack + +const getOrderedCircles = createSelector([state => state.get('circles')], circles => { + if (!circles) { + return circles; + } + + return circles.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); +}); + +const mapStateToProps = state => ({ + circleIds: getOrderedCircles(state).map(circle=>circle.get('id')), +}); + +const mapDispatchToProps = dispatch => ({ + onInitialize: accountId => dispatch(setupCircleAdder(accountId)), + onReset: () => dispatch(resetCircleAdder()), +}); + +class CircleAdder extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + onInitialize: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + circleIds: ImmutablePropTypes.list.isRequired, + }; + + componentDidMount () { + const { onInitialize, accountId } = this.props; + onInitialize(accountId); + } + + componentWillUnmount () { + const { onReset } = this.props; + onReset(); + } + + render () { + const { accountId, circleIds } = this.props; + + return ( +
+
+ +
+ + + + +
+ {circleIds.map(CircleId => )} +
+
+ ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(CircleAdder)); diff --git a/app/javascript/mastodon/features/circle_editor/components/account.jsx b/app/javascript/mastodon/features/circle_editor/components/account.jsx new file mode 100644 index 0000000000..a14f25b8fe --- /dev/null +++ b/app/javascript/mastodon/features/circle_editor/components/account.jsx @@ -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 { removeFromCircleEditor, addToCircleEditor } from '../../../actions/circles'; +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: 'circles.account.remove', defaultMessage: 'Remove from circle' }, + add: { id: 'circles.account.add', defaultMessage: 'Add to circle' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId, added }) => ({ + account: getAccount(state, accountId), + added: typeof added === 'undefined' ? state.getIn(['circleEditor', 'accounts', 'items']).includes(accountId) : added, + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { accountId }) => ({ + onRemove: () => dispatch(removeFromCircleEditor(accountId)), + onAdd: () => dispatch(addToCircleEditor(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 = ; + } else { + button = ; + } + + return ( +
+
+
+
+ +
+ +
+ {button} +
+
+
+ ); + } + +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(Account)); diff --git a/app/javascript/mastodon/features/circle_editor/components/edit_circle_form.jsx b/app/javascript/mastodon/features/circle_editor/components/edit_circle_form.jsx new file mode 100644 index 0000000000..55c0298351 --- /dev/null +++ b/app/javascript/mastodon/features/circle_editor/components/edit_circle_form.jsx @@ -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 { changeCircleEditorTitle, submitCircleEditor } from '../../../actions/circles'; +import { IconButton } from '../../../components/icon_button'; + +const messages = defineMessages({ + title: { id: 'circles.edit.submit', defaultMessage: 'Change title' }, +}); + +const mapStateToProps = state => ({ + value: state.getIn(['circleEditor', 'title']), + disabled: !state.getIn(['circleEditor', 'isChanged']) || !state.getIn(['circleEditor', 'title']), +}); + +const mapDispatchToProps = dispatch => ({ + onChange: value => dispatch(changeCircleEditorTitle(value)), + onSubmit: () => dispatch(submitCircleEditor(false)), +}); + +class CircleForm 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 ( +
+ + + + + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(CircleForm)); diff --git a/app/javascript/mastodon/features/circle_editor/components/search.jsx b/app/javascript/mastodon/features/circle_editor/components/search.jsx new file mode 100644 index 0000000000..93697ee08e --- /dev/null +++ b/app/javascript/mastodon/features/circle_editor/components/search.jsx @@ -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 { fetchCircleSuggestions, clearCircleSuggestions, changeCircleSuggestions } from '../../../actions/circles'; + +const messages = defineMessages({ + search: { id: 'circles.search', defaultMessage: 'Search among people you follow' }, +}); + +const mapStateToProps = state => ({ + value: state.getIn(['circleEditor', 'suggestions', 'value']), +}); + +const mapDispatchToProps = dispatch => ({ + onSubmit: value => dispatch(fetchCircleSuggestions(value)), + onClear: () => dispatch(clearCircleSuggestions()), + onChange: value => dispatch(changeCircleSuggestions(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 ( +
+ + +
+ + +
+
+ ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Search)); diff --git a/app/javascript/mastodon/features/circle_editor/index.jsx b/app/javascript/mastodon/features/circle_editor/index.jsx new file mode 100644 index 0000000000..6d47b86c3d --- /dev/null +++ b/app/javascript/mastodon/features/circle_editor/index.jsx @@ -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 { setupCircleEditor, clearCircleSuggestions, resetCircleEditor } from '../../actions/circles'; +import Motion from '../ui/util/optional_motion'; + +import Account from './components/account'; +import EditCircleForm from './components/edit_circle_form'; +import Search from './components/search'; + +const mapStateToProps = state => ({ + accountIds: state.getIn(['circleEditor', 'accounts', 'items']), + searchAccountIds: state.getIn(['circleEditor', 'suggestions', 'items']), +}); + +const mapDispatchToProps = dispatch => ({ + onInitialize: circleId => dispatch(setupCircleEditor(circleId)), + onClear: () => dispatch(clearCircleSuggestions()), + onReset: () => dispatch(resetCircleEditor()), +}); + +class CircleEditor extends ImmutablePureComponent { + + static propTypes = { + circleId: 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, circleId } = this.props; + onInitialize(circleId); + } + + componentWillUnmount () { + const { onReset } = this.props; + onReset(); + } + + render () { + const { accountIds, searchAccountIds, onClear } = this.props; + const showSearch = searchAccountIds.size > 0; + + return ( +
+ + + + +
+
+ {accountIds.map(accountId => )} +
+ + {showSearch &&
} + + + {({ x }) => ( +
+ {searchAccountIds.map(accountId => )} +
+ )} +
+
+
+ ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(CircleEditor)); diff --git a/app/javascript/mastodon/features/circles/components/new_circle_form.jsx b/app/javascript/mastodon/features/circles/components/new_circle_form.jsx new file mode 100644 index 0000000000..b3fb2dbadd --- /dev/null +++ b/app/javascript/mastodon/features/circles/components/new_circle_form.jsx @@ -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 { changeCircleEditorTitle, submitCircleEditor } from 'mastodon/actions/circles'; +import Button from 'mastodon/components/button'; + +const messages = defineMessages({ + label: { id: 'circles.new.title_placeholder', defaultMessage: 'New circle title' }, + title: { id: 'circles.new.create', defaultMessage: 'Add circle' }, +}); + +const mapStateToProps = state => ({ + value: state.getIn(['circleEditor', 'title']), + disabled: state.getIn(['circleEditor', 'isSubmitting']), +}); + +const mapDispatchToProps = dispatch => ({ + onChange: value => dispatch(changeCircleEditorTitle(value)), + onSubmit: () => dispatch(submitCircleEditor(true)), +}); + +class NewCircleForm 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 ( +
+ + +