diff --git a/app/assets/javascripts/components/actions/favourites.jsx b/app/assets/javascripts/components/actions/favourites.jsx
new file mode 100644
index 00000000..a25c1ae1
--- /dev/null
+++ b/app/assets/javascripts/components/actions/favourites.jsx
@@ -0,0 +1,83 @@
+import api, { getLinks } from '../api'
+
+export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
+export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
+export const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL';
+
+export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST';
+export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS';
+export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL';
+
+export function fetchFavouritedStatuses() {
+ return (dispatch, getState) => {
+ dispatch(fetchFavouritedStatusesRequest());
+
+ api(getState).get('/api/v1/favourites').then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(fetchFavouritedStatusesFail(error));
+ });
+ };
+};
+
+export function fetchFavouritedStatusesRequest() {
+ return {
+ type: FAVOURITED_STATUSES_FETCH_REQUEST
+ };
+};
+
+export function fetchFavouritedStatusesSuccess(statuses, next) {
+ return {
+ type: FAVOURITED_STATUSES_FETCH_SUCCESS,
+ statuses,
+ next
+ };
+};
+
+export function fetchFavouritedStatusesFail(error) {
+ return {
+ type: FAVOURITED_STATUSES_FETCH_FAIL,
+ error
+ };
+};
+
+export function expandFavouritedStatuses() {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['status_lists', 'favourites', 'next'], null);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandFavouritedStatusesRequest());
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(expandFavouritedStatusesFail(error));
+ });
+ };
+};
+
+export function expandFavouritedStatusesRequest() {
+ return {
+ type: FAVOURITED_STATUSES_EXPAND_REQUEST
+ };
+};
+
+export function expandFavouritedStatusesSuccess(statuses, next) {
+ return {
+ type: FAVOURITED_STATUSES_EXPAND_SUCCESS,
+ statuses,
+ next
+ };
+};
+
+export function expandFavouritedStatusesFail(error) {
+ return {
+ type: FAVOURITED_STATUSES_EXPAND_FAIL,
+ error
+ };
+};
diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx
index 8bb939d3..975a0e09 100644
--- a/app/assets/javascripts/components/actions/timelines.jsx
+++ b/app/assets/javascripts/components/actions/timelines.jsx
@@ -97,6 +97,11 @@ export function expandTimeline(timeline, id = null) {
return (dispatch, getState) => {
const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last();
+ if (!lastId) {
+ // If timeline is empty, don't try to load older posts since there are none
+ return;
+ }
+
dispatch(expandTimelineRequest(timeline));
let path = timeline;
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index af495652..5f4b2cf7 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -34,6 +34,7 @@ import HashtagTimeline from '../features/hashtag_timeline';
import Notifications from '../features/notifications';
import FollowRequests from '../features/follow_requests';
import GenericNotFound from '../features/generic_not_found';
+import FavouritedStatuses from '../features/favourited_statuses';
import { IntlProvider, addLocaleData } from 'react-intl';
import en from 'react-intl/locale-data/en';
import de from 'react-intl/locale-data/de';
@@ -113,6 +114,7 @@ const Mastodon = React.createClass({
+
diff --git a/app/assets/javascripts/components/features/account_timeline/index.jsx b/app/assets/javascripts/components/features/account_timeline/index.jsx
index 7a3dbe16..4a66dbbf 100644
--- a/app/assets/javascripts/components/features/account_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/index.jsx
@@ -18,7 +18,8 @@ const AccountTimeline = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
- statusIds: ImmutablePropTypes.list
+ statusIds: ImmutablePropTypes.list,
+ me: React.PropTypes.number.isRequired
},
mixins: [PureRenderMixin],
diff --git a/app/assets/javascripts/components/features/favourited_statuses/index.jsx b/app/assets/javascripts/components/features/favourited_statuses/index.jsx
new file mode 100644
index 00000000..a2d52173
--- /dev/null
+++ b/app/assets/javascripts/components/features/favourited_statuses/index.jsx
@@ -0,0 +1,63 @@
+import { connect } from 'react-redux';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
+import Column from '../ui/components/column';
+import StatusList from '../../components/status_list';
+import ColumnBackButton from '../public_timeline/components/column_back_button';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+ heading: { id: 'column.favourites', defaultMessage: 'Favourites' }
+});
+
+const mapStateToProps = state => ({
+ statusIds: state.getIn(['status_lists', 'favourites', 'items']),
+ loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
+ me: state.getIn(['meta', 'me'])
+});
+
+const Favourites = React.createClass({
+
+ propTypes: {
+ params: React.PropTypes.object.isRequired,
+ dispatch: React.PropTypes.func.isRequired,
+ statusIds: ImmutablePropTypes.list.isRequired,
+ loaded: React.PropTypes.bool,
+ intl: React.PropTypes.object.isRequired,
+ me: React.PropTypes.number.isRequired
+ },
+
+ mixins: [PureRenderMixin],
+
+ componentWillMount () {
+ this.props.dispatch(fetchFavouritedStatuses());
+ },
+
+ handleScrollToBottom () {
+ this.props.dispatch(expandFavouritedStatuses());
+ },
+
+ render () {
+ const { statusIds, loaded, intl, me } = this.props;
+
+ if (!loaded) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+ }
+
+});
+
+export default connect(mapStateToProps)(injectIntl(Favourites));
diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx
index d7c2f8df..42e0a9e2 100644
--- a/app/assets/javascripts/components/features/getting_started/index.jsx
+++ b/app/assets/javascripts/components/features/getting_started/index.jsx
@@ -10,7 +10,8 @@ const messages = defineMessages({
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
- sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' }
+ sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' },
+ favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }
});
const mapStateToProps = state => ({
@@ -29,6 +30,7 @@ const GettingStarted = ({ intl, me }) => {
+
{followRequests}
diff --git a/app/assets/javascripts/components/middleware/loading_bar.jsx b/app/assets/javascripts/components/middleware/loading_bar.jsx
new file mode 100644
index 00000000..a98f1bb2
--- /dev/null
+++ b/app/assets/javascripts/components/middleware/loading_bar.jsx
@@ -0,0 +1,25 @@
+import { showLoading, hideLoading } from 'react-redux-loading-bar';
+
+const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED'];
+
+export default function loadingBarMiddleware(config = {}) {
+ const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes;
+
+ return ({ dispatch }) => next => (action) => {
+ if (action.type && !action.skipLoading) {
+ const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes;
+
+ const isPending = new RegExp(`${PENDING}$`, 'g');
+ const isFulfilled = new RegExp(`${FULFILLED}$`, 'g');
+ const isRejected = new RegExp(`${REJECTED}$`, 'g');
+
+ if (action.type.match(isPending)) {
+ dispatch(showLoading());
+ } else if (action.type.match(isFulfilled) || action.type.match(isRejected)) {
+ dispatch(hideLoading());
+ }
+ }
+
+ return next(action);
+ };
+};
diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx
index ae048df3..73dee907 100644
--- a/app/assets/javascripts/components/reducers/accounts.jsx
+++ b/app/assets/javascripts/components/reducers/accounts.jsx
@@ -32,6 +32,10 @@ import {
NOTIFICATIONS_REFRESH_SUCCESS,
NOTIFICATIONS_EXPAND_SUCCESS
} from '../actions/notifications';
+import {
+ FAVOURITED_STATUSES_FETCH_SUCCESS,
+ FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
import { STORE_HYDRATE } from '../actions/store';
import Immutable from 'immutable';
@@ -90,6 +94,8 @@ export default function accounts(state = initialState, action) {
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
case CONTEXT_FETCH_SUCCESS:
+ case FAVOURITED_STATUSES_FETCH_SUCCESS:
+ case FAVOURITED_STATUSES_EXPAND_SUCCESS:
return normalizeAccountsFromStatuses(state, action.statuses);
case REBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx
index 06849194..80c913d2 100644
--- a/app/assets/javascripts/components/reducers/index.jsx
+++ b/app/assets/javascripts/components/reducers/index.jsx
@@ -12,6 +12,7 @@ import relationships from './relationships';
import search from './search';
import notifications from './notifications';
import settings from './settings';
+import status_lists from './status_lists';
export default combineReducers({
timelines,
@@ -21,6 +22,7 @@ export default combineReducers({
loadingBar: loadingBarReducer,
modal,
user_lists,
+ status_lists,
accounts,
statuses,
relationships,
diff --git a/app/assets/javascripts/components/reducers/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx
index b529b6aa..ac53ea21 100644
--- a/app/assets/javascripts/components/reducers/modal.jsx
+++ b/app/assets/javascripts/components/reducers/modal.jsx
@@ -8,14 +8,14 @@ const initialState = Immutable.Map({
export default function modal(state = initialState, action) {
switch(action.type) {
- case MEDIA_OPEN:
- return state.withMutations(map => {
- map.set('url', action.url);
- map.set('open', true);
- });
- case MODAL_CLOSE:
- return state.set('open', false);
- default:
- return state;
+ case MEDIA_OPEN:
+ return state.withMutations(map => {
+ map.set('url', action.url);
+ map.set('open', true);
+ });
+ case MODAL_CLOSE:
+ return state.set('open', false);
+ default:
+ return state;
}
};
diff --git a/app/assets/javascripts/components/reducers/status_lists.jsx b/app/assets/javascripts/components/reducers/status_lists.jsx
new file mode 100644
index 00000000..b883b1c5
--- /dev/null
+++ b/app/assets/javascripts/components/reducers/status_lists.jsx
@@ -0,0 +1,39 @@
+import {
+ FAVOURITED_STATUSES_FETCH_SUCCESS,
+ FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+ favourites: Immutable.Map({
+ next: null,
+ loaded: false,
+ items: Immutable.List()
+ })
+});
+
+const normalizeList = (state, listType, statuses, next) => {
+ return state.update(listType, listMap => listMap.withMutations(map => {
+ map.set('next', next);
+ map.set('loaded', true);
+ map.set('items', Immutable.List(statuses.map(item => item.id)));
+ }));
+};
+
+const appendToList = (state, listType, statuses, next) => {
+ return state.update(listType, listMap => listMap.withMutations(map => {
+ map.set('next', next);
+ map.set('items', map.get('items').push(...statuses.map(item => item.id)));
+ }));
+};
+
+export default function statusLists(state = initialState, action) {
+ switch(action.type) {
+ case FAVOURITED_STATUSES_FETCH_SUCCESS:
+ return normalizeList(state, 'favourites', action.statuses, action.next);
+ case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+ return appendToList(state, 'favourites', action.statuses, action.next);
+ default:
+ return state;
+ }
+};
diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx
index c740b6d6..084b6304 100644
--- a/app/assets/javascripts/components/reducers/statuses.jsx
+++ b/app/assets/javascripts/components/reducers/statuses.jsx
@@ -28,6 +28,10 @@ import {
NOTIFICATIONS_REFRESH_SUCCESS,
NOTIFICATIONS_EXPAND_SUCCESS
} from '../actions/notifications';
+import {
+ FAVOURITED_STATUSES_FETCH_SUCCESS,
+ FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
import Immutable from 'immutable';
const normalizeStatus = (state, status) => {
@@ -77,36 +81,38 @@ const initialState = Immutable.Map();
export default function statuses(state = initialState, action) {
switch(action.type) {
- case TIMELINE_UPDATE:
- case STATUS_FETCH_SUCCESS:
- case NOTIFICATIONS_UPDATE:
- return normalizeStatus(state, action.status);
- case REBLOG_SUCCESS:
- case UNREBLOG_SUCCESS:
- case FAVOURITE_SUCCESS:
- case UNFAVOURITE_SUCCESS:
- return normalizeStatus(state, action.response);
- case FAVOURITE_REQUEST:
- return state.setIn([action.status.get('id'), 'favourited'], true);
- case FAVOURITE_FAIL:
- return state.setIn([action.status.get('id'), 'favourited'], false);
- case REBLOG_REQUEST:
- return state.setIn([action.status.get('id'), 'reblogged'], true);
- case REBLOG_FAIL:
- return state.setIn([action.status.get('id'), 'reblogged'], false);
- case TIMELINE_REFRESH_SUCCESS:
- case TIMELINE_EXPAND_SUCCESS:
- case ACCOUNT_TIMELINE_FETCH_SUCCESS:
- case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
- case CONTEXT_FETCH_SUCCESS:
- case NOTIFICATIONS_REFRESH_SUCCESS:
- case NOTIFICATIONS_EXPAND_SUCCESS:
- return normalizeStatuses(state, action.statuses);
- case TIMELINE_DELETE:
- return deleteStatus(state, action.id, action.references);
- case ACCOUNT_BLOCK_SUCCESS:
- return filterStatuses(state, action.relationship);
- default:
- return state;
+ case TIMELINE_UPDATE:
+ case STATUS_FETCH_SUCCESS:
+ case NOTIFICATIONS_UPDATE:
+ return normalizeStatus(state, action.status);
+ case REBLOG_SUCCESS:
+ case UNREBLOG_SUCCESS:
+ case FAVOURITE_SUCCESS:
+ case UNFAVOURITE_SUCCESS:
+ return normalizeStatus(state, action.response);
+ case FAVOURITE_REQUEST:
+ return state.setIn([action.status.get('id'), 'favourited'], true);
+ case FAVOURITE_FAIL:
+ return state.setIn([action.status.get('id'), 'favourited'], false);
+ case REBLOG_REQUEST:
+ return state.setIn([action.status.get('id'), 'reblogged'], true);
+ case REBLOG_FAIL:
+ return state.setIn([action.status.get('id'), 'reblogged'], false);
+ case TIMELINE_REFRESH_SUCCESS:
+ case TIMELINE_EXPAND_SUCCESS:
+ case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+ case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+ case CONTEXT_FETCH_SUCCESS:
+ case NOTIFICATIONS_REFRESH_SUCCESS:
+ case NOTIFICATIONS_EXPAND_SUCCESS:
+ case FAVOURITED_STATUSES_FETCH_SUCCESS:
+ case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+ return normalizeStatuses(state, action.statuses);
+ case TIMELINE_DELETE:
+ return deleteStatus(state, action.id, action.references);
+ case ACCOUNT_BLOCK_SUCCESS:
+ return filterStatuses(state, action.relationship);
+ default:
+ return state;
}
};
diff --git a/app/assets/javascripts/components/reducers/user_lists.jsx b/app/assets/javascripts/components/reducers/user_lists.jsx
index 36093663..72922f50 100644
--- a/app/assets/javascripts/components/reducers/user_lists.jsx
+++ b/app/assets/javascripts/components/reducers/user_lists.jsx
@@ -36,24 +36,24 @@ const appendToList = (state, type, id, accounts, next) => {
export default function userLists(state = initialState, action) {
switch(action.type) {
- case FOLLOWERS_FETCH_SUCCESS:
- return normalizeList(state, 'followers', action.id, action.accounts, action.next);
- case FOLLOWERS_EXPAND_SUCCESS:
- return appendToList(state, 'followers', action.id, action.accounts, action.next);
- case FOLLOWING_FETCH_SUCCESS:
- return normalizeList(state, 'following', action.id, action.accounts, action.next);
- case FOLLOWING_EXPAND_SUCCESS:
- return appendToList(state, 'following', action.id, action.accounts, action.next);
- case REBLOGS_FETCH_SUCCESS:
- return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
- case FAVOURITES_FETCH_SUCCESS:
- return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
- case FOLLOW_REQUESTS_FETCH_SUCCESS:
- return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
- case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
- case FOLLOW_REQUEST_REJECT_SUCCESS:
- return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
- default:
- return state;
+ case FOLLOWERS_FETCH_SUCCESS:
+ return normalizeList(state, 'followers', action.id, action.accounts, action.next);
+ case FOLLOWERS_EXPAND_SUCCESS:
+ return appendToList(state, 'followers', action.id, action.accounts, action.next);
+ case FOLLOWING_FETCH_SUCCESS:
+ return normalizeList(state, 'following', action.id, action.accounts, action.next);
+ case FOLLOWING_EXPAND_SUCCESS:
+ return appendToList(state, 'following', action.id, action.accounts, action.next);
+ case REBLOGS_FETCH_SUCCESS:
+ return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
+ case FAVOURITES_FETCH_SUCCESS:
+ return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
+ case FOLLOW_REQUESTS_FETCH_SUCCESS:
+ return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+ case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
+ case FOLLOW_REQUEST_REJECT_SUCCESS:
+ return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
+ default:
+ return state;
}
};
diff --git a/app/assets/javascripts/components/store/configureStore.jsx b/app/assets/javascripts/components/store/configureStore.jsx
index 2c1476e5..87f46999 100644
--- a/app/assets/javascripts/components/store/configureStore.jsx
+++ b/app/assets/javascripts/components/store/configureStore.jsx
@@ -1,7 +1,7 @@
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import appReducer from '../reducers';
-import { loadingBarMiddleware } from 'react-redux-loading-bar';
+import loadingBarMiddleware from '../middleware/loading_bar';
import errorsMiddleware from '../middleware/errors';
import Immutable from 'immutable';
diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb
index a71592ac..ea799fd5 100644
--- a/app/controllers/api/v1/favourites_controller.rb
+++ b/app/controllers/api/v1/favourites_controller.rb
@@ -13,7 +13,7 @@ class Api::V1::FavouritesController < ApiController
set_maps(@statuses)
set_counters_maps(@statuses)
- next_path = api_v1_favourites_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT
+ next_path = api_v1_favourites_url(max_id: results.last.id) if results.size == DEFAULT_STATUSES_LIMIT
prev_path = api_v1_favourites_url(since_id: results.first.id) unless results.empty?
set_pagination_headers(next_path, prev_path)