diff --git a/app/javascript/mastodon/actions/circles.js b/app/javascript/mastodon/actions/circles.js index 6a52e541c9a440..a497b27d5db592 100644 --- a/app/javascript/mastodon/actions/circles.js +++ b/app/javascript/mastodon/actions/circles.js @@ -1,7 +1,7 @@ -import api from '../api'; +import api, { getLinks } from '../api'; import { showAlertForError } from './alerts'; -import { importFetchedAccounts } from './importer'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; export const CIRCLE_FETCH_REQUEST = 'CIRCLE_FETCH_REQUEST'; export const CIRCLE_FETCH_SUCCESS = 'CIRCLE_FETCH_SUCCESS'; @@ -50,6 +50,14 @@ export const CIRCLE_ADDER_CIRCLES_FETCH_REQUEST = 'CIRCLE_ADDER_CIRCLES_FETCH_RE 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 CIRCLE_STATUSES_FETCH_REQUEST = 'CIRCLE_STATUSES_FETCH_REQUEST'; +export const CIRCLE_STATUSES_FETCH_SUCCESS = 'CIRCLE_STATUSES_FETCH_SUCCESS'; +export const CIRCLE_STATUSES_FETCH_FAIL = 'CIRCLE_STATUSES_FETCH_FAIL'; + +export const CIRCLE_STATUSES_EXPAND_REQUEST = 'CIRCLE_STATUSES_EXPAND_REQUEST'; +export const CIRCLE_STATUSES_EXPAND_SUCCESS = 'CIRCLE_STATUSES_EXPAND_SUCCESS'; +export const CIRCLE_STATUSES_EXPAND_FAIL = 'CIRCLE_STATUSES_EXPAND_FAIL'; + export const fetchCircle = id => (dispatch, getState) => { if (getState().getIn(['circles', id])) { return; @@ -370,3 +378,89 @@ export const removeFromCircleAdder = circleId => (dispatch, getState) => { dispatch(removeFromCircle(circleId, getState().getIn(['circleAdder', 'accountId']))); }; +export function fetchCircleStatuses(circleId) { + return (dispatch, getState) => { + if (getState().getIn(['circles', circleId, 'statuses', 'isLoading'])) { + return; + } + + dispatch(fetchCircleStatusesRequest(circleId)); + + api(getState).get(`/api/v1/circles/${circleId}/statuses`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchCircleStatusesSuccess(circleId, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchCircleStatusesFail(circleId, error)); + }); + }; +} + +export function fetchCircleStatusesRequest(id) { + return { + type: CIRCLE_STATUSES_FETCH_REQUEST, + id, + }; +} + +export function fetchCircleStatusesSuccess(id, statuses, next) { + return { + type: CIRCLE_STATUSES_FETCH_SUCCESS, + id, + statuses, + next, + }; +} + +export function fetchCircleStatusesFail(id, error) { + return { + type: CIRCLE_STATUSES_FETCH_FAIL, + id, + error, + }; +} + +export function expandCircleStatuses(circleId) { + return (dispatch, getState) => { + const url = getState().getIn(['circles', circleId, 'statuses', 'next'], null); + + if (url === null || getState().getIn(['circles', circleId, 'statuses', 'isLoading'])) { + return; + } + + dispatch(expandCircleStatusesRequest(circleId)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandCircleStatusesSuccess(circleId, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandCircleStatusesFail(circleId, error)); + }); + }; +} + +export function expandCircleStatusesRequest(id) { + return { + type: CIRCLE_STATUSES_EXPAND_REQUEST, + id, + }; +} + +export function expandCircleStatusesSuccess(id, statuses, next) { + return { + type: CIRCLE_STATUSES_EXPAND_SUCCESS, + id, + statuses, + next, + }; +} + +export function expandCircleStatusesFail(id, error) { + return { + type: CIRCLE_STATUSES_EXPAND_FAIL, + id, + error, + }; +} + diff --git a/app/javascript/mastodon/features/circle_statuses/index.jsx b/app/javascript/mastodon/features/circle_statuses/index.jsx new file mode 100644 index 00000000000000..4ec0b5da978521 --- /dev/null +++ b/app/javascript/mastodon/features/circle_statuses/index.jsx @@ -0,0 +1,179 @@ +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 { debounce } from 'lodash'; + +import { deleteCircle, expandCircleStatuses, fetchCircle, fetchCircleStatuses , setupCircleEditor } from 'mastodon/actions/circles'; +import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; +import { openModal } from 'mastodon/actions/modal'; +import ColumnHeader from 'mastodon/components/column_header'; +import { Icon } from 'mastodon/components/icon'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import StatusList from 'mastodon/components/status_list'; +import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; +import Column from 'mastodon/features/ui/components/column'; +import { getCircleStatusList } from 'mastodon/selectors'; + + +const messages = defineMessages({ + deleteMessage: { id: 'confirmations.delete_circle.message', defaultMessage: 'Are you sure you want to permanently delete this circle?' }, + deleteConfirm: { id: 'confirmations.delete_circle.confirm', defaultMessage: 'Delete' }, + heading: { id: 'column.circles', defaultMessage: 'Circles' }, +}); + +const mapStateToProps = (state, { params }) => ({ + circle: state.getIn(['circles', params.id]), + statusIds: getCircleStatusList(state, params.id), + isLoading: state.getIn(['circles', params.id, 'isLoading'], true), + isEditing: state.getIn(['circleEditor', 'circleId']) === params.id, + hasMore: !!state.getIn(['circles', params.id, 'next']), +}); + +class CircleStatuses extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + circle: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]), + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + }; + + UNSAFE_componentWillMount () { + this.props.dispatch(fetchCircle(this.props.params.id)); + this.props.dispatch(fetchCircleStatuses(this.props.params.id)); + } + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('CIRCLE_STATUSES', { 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(); + }; + + handleEditClick = () => { + this.props.dispatch(setupCircleEditor(this.props.params.id)); + }; + + 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(deleteCircle(id)); + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + this.context.router.history.push('/circles'); + } + }, + }, + })); + }; + + setRef = c => { + this.column = c; + }; + + handleLoadMore = debounce(() => { + this.props.dispatch(expandCircleStatuses()); + }, 300, { leading: true }); + + render () { + const { intl, circle, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; + const pinned = !!columnId; + + if (typeof circle === 'undefined') { + return ( + +
+ +
+
+ ); + } else if (circle === false) { + return ( + + ); + } + + const emptyMessage = ; + + return ( + + +
+ + + +
+
+ + + + + {intl.formatMessage(messages.heading)} + + +
+ ); + } + +} + +export default connect(mapStateToProps)(injectIntl(CircleStatuses)); diff --git a/app/javascript/mastodon/reducers/circles.js b/app/javascript/mastodon/reducers/circles.js index 805d7f186addf5..c07e9406b92203 100644 --- a/app/javascript/mastodon/reducers/circles.js +++ b/app/javascript/mastodon/reducers/circles.js @@ -1,4 +1,4 @@ -import { List as ImmutableList, fromJS } from 'immutable'; +import { List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import { CIRCLE_FETCH_SUCCESS, @@ -7,6 +7,12 @@ import { CIRCLE_CREATE_SUCCESS, CIRCLE_UPDATE_SUCCESS, CIRCLE_DELETE_SUCCESS, + CIRCLE_STATUSES_FETCH_REQUEST, + CIRCLE_STATUSES_FETCH_SUCCESS, + CIRCLE_STATUSES_FETCH_FAIL, + CIRCLE_STATUSES_EXPAND_REQUEST, + CIRCLE_STATUSES_EXPAND_SUCCESS, + CIRCLE_STATUSES_EXPAND_FAIL, } from '../actions/circles'; const initialState = ImmutableList(); @@ -21,6 +27,31 @@ const normalizeLists = (state, circles) => { return state; }; +const normalizeCircleStatuses = (state, circleId, statuses, next) => { + return state.updateIn([circleId, 'statuses'], listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('loaded', true); + map.set('isLoading', false); + map.set('items', ImmutableOrderedSet(statuses.map(item => item.id))); + })); +}; + +const appendToCircleStatuses = (state, circleId, statuses, next) => { + return appendToCircleStatusesById(state, circleId, statuses.map(item => item.id), next); +}; + +const appendToCircleStatusesById = (state, circleId, statuses, next) => { + return state.updateIn([circleId, 'statuses'], listMap => listMap.withMutations(map => { + if (typeof next !== 'undefined') { + map.set('next', next); + } + map.set('isLoading', false); + if (map.get('items')) { + map.set('items', map.get('items').union(statuses)); + } + })); +}; + export default function circles(state = initialState, action) { switch(action.type) { case CIRCLE_FETCH_SUCCESS: @@ -32,6 +63,16 @@ export default function circles(state = initialState, action) { case CIRCLE_DELETE_SUCCESS: case CIRCLE_FETCH_FAIL: return state.set(action.id, false); + case CIRCLE_STATUSES_FETCH_REQUEST: + case CIRCLE_STATUSES_EXPAND_REQUEST: + return state.setIn([action.id, 'statuses', 'isLoading'], true); + case CIRCLE_STATUSES_FETCH_FAIL: + case CIRCLE_STATUSES_EXPAND_FAIL: + return state.setIn([action.id, 'statuses', 'isLoading'], false); + case CIRCLE_STATUSES_FETCH_SUCCESS: + return normalizeCircleStatuses(state, action.id, action.statuses, action.next); + case CIRCLE_STATUSES_EXPAND_SUCCESS: + return appendToCircleStatuses(state, action.id, action.statuses, action.next); default: return state; } diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 6d5adbeb48f5a7..f6398e93240600 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -135,3 +135,7 @@ export const getStatusList = createSelector([ export const getBookmarkCategoryStatusList = createSelector([ (state, bookmarkCategoryId) => state.getIn(['bookmark_categories', bookmarkCategoryId, 'items']), ], (items) => items ? items.toList() : ImmutableList()); + +export const getCircleStatusList = createSelector([ + (state, circleId) => state.getIn(['circles', circleId, 'statuses', 'items']), +], (items) => items ? items.toList() : ImmutableList());