Add listing of followed hashtags (#21773)
* Add followed_tags route. This at least gets us to the point where the page can actually be rendered, although it doesn't display any hashtags (yet?). Attempting to implement #20763. * Fix minor issues. * I've got the followed tags data partially working But the Hashtag component errors for some reason. Something about the value of the history attribute being invalid. * Fix a mistake in the code * Minor change. * Get the followed hashtags list fully working. Still need to add the Follow/Unfollow buttons, though. * Resolve JS linter issues. * Add pagination logic to followed tags list view. However, it currently loads further pages immediately on page load, so that's not ideal. Need to figure that one out. * Appease the linter. * Apply suggestions from code review Co-authored-by: Claire <claire.github-309c@sitedethib.com> * Fixes and resolve some other feedback. * Use set/update instead of setIn/updateIn. Co-authored-by: Claire <claire.github-309c@sitedethib.com>gh/stable
parent
3970a6f433
commit
30e895299c
|
@ -1,9 +1,17 @@
|
||||||
import api from '../api';
|
import api, { getLinks } from '../api';
|
||||||
|
|
||||||
export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
|
export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
|
||||||
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
||||||
export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
|
export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
|
||||||
|
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
|
||||||
|
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
|
||||||
|
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
|
||||||
|
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
|
||||||
|
|
||||||
export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
|
export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
|
||||||
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
||||||
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
||||||
|
@ -37,6 +45,78 @@ export const fetchHashtagFail = error => ({
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const fetchFollowedHashtags = () => (dispatch, getState) => {
|
||||||
|
dispatch(fetchFollowedHashtagsRequest());
|
||||||
|
|
||||||
|
api(getState).get('/api/v1/followed_tags').then(response => {
|
||||||
|
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||||
|
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||||
|
}).catch(err => {
|
||||||
|
dispatch(fetchFollowedHashtagsFail(err));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchFollowedHashtagsRequest() {
|
||||||
|
return {
|
||||||
|
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchFollowedHashtagsSuccess(followed_tags, next) {
|
||||||
|
return {
|
||||||
|
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||||
|
followed_tags,
|
||||||
|
next,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchFollowedHashtagsFail(error) {
|
||||||
|
return {
|
||||||
|
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function expandFollowedHashtags() {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
const url = getState().getIn(['followed_tags', 'next']);
|
||||||
|
|
||||||
|
if (url === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(expandFollowedHashtagsRequest());
|
||||||
|
|
||||||
|
api(getState).get(url).then(response => {
|
||||||
|
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||||
|
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(expandFollowedHashtagsFail(error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function expandFollowedHashtagsRequest() {
|
||||||
|
return {
|
||||||
|
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function expandFollowedHashtagsSuccess(followed_tags, next) {
|
||||||
|
return {
|
||||||
|
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||||
|
followed_tags,
|
||||||
|
next,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function expandFollowedHashtagsFail(error) {
|
||||||
|
return {
|
||||||
|
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const followHashtag = name => (dispatch, getState) => {
|
export const followHashtag = name => (dispatch, getState) => {
|
||||||
dispatch(followHashtagRequest(name));
|
dispatch(followHashtagRequest(name));
|
||||||
|
|
||||||
|
|
|
@ -46,6 +46,7 @@ const messages = defineMessages({
|
||||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||||
|
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
|
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
|
||||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||||
|
@ -242,6 +243,7 @@ class Header extends ImmutablePureComponent {
|
||||||
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
|
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
|
||||||
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
|
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
|
||||||
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
||||||
|
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
||||||
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
||||||
|
|
|
@ -11,6 +11,7 @@ const messages = defineMessages({
|
||||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||||
|
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
||||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||||
|
@ -45,6 +46,7 @@ class ActionBar extends React.PureComponent {
|
||||||
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
|
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
|
||||||
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
|
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
|
||||||
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
||||||
|
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
||||||
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import ColumnHeader from 'mastodon/components/column_header';
|
||||||
|
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||||
|
import Column from 'mastodon/features/ui/components/column';
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
import Hashtag from 'mastodon/components/hashtag';
|
||||||
|
import { expandFollowedHashtags, fetchFollowedHashtags } from 'mastodon/actions/tags';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
hashtags: state.getIn(['followed_tags', 'items']),
|
||||||
|
isLoading: state.getIn(['followed_tags', 'isLoading'], true),
|
||||||
|
hasMore: !!state.getIn(['followed_tags', 'next']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
@injectIntl
|
||||||
|
class FollowedTags extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
params: PropTypes.object.isRequired,
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
hashtags: ImmutablePropTypes.list,
|
||||||
|
isLoading: PropTypes.bool,
|
||||||
|
hasMore: PropTypes.bool,
|
||||||
|
multiColumn: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.dispatch(fetchFollowedHashtags());
|
||||||
|
};
|
||||||
|
|
||||||
|
handleLoadMore = debounce(() => {
|
||||||
|
this.props.dispatch(expandFollowedHashtags());
|
||||||
|
}, 300, { leading: true });
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
|
||||||
|
|
||||||
|
const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column bindToDocument={!multiColumn}>
|
||||||
|
<ColumnHeader
|
||||||
|
icon='hashtag'
|
||||||
|
title={intl.formatMessage(messages.heading)}
|
||||||
|
showBackButton
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollableList
|
||||||
|
scrollKey='followed_tags'
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
hasMore={hasMore}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onLoadMore={this.handleLoadMore}
|
||||||
|
bindToDocument={!multiColumn}
|
||||||
|
>
|
||||||
|
{hashtags.map((hashtag) => (
|
||||||
|
<Hashtag
|
||||||
|
key={hashtag.get('name')}
|
||||||
|
name={hashtag.get('name')}
|
||||||
|
to={`/tags/${hashtag.get('name')}`}
|
||||||
|
withGraph={false}
|
||||||
|
// Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options?
|
||||||
|
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
|
||||||
|
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollableList>
|
||||||
|
|
||||||
|
<Helmet>
|
||||||
|
<meta name='robots' content='noindex' />
|
||||||
|
</Helmet>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -42,6 +42,7 @@ import {
|
||||||
FollowRequests,
|
FollowRequests,
|
||||||
FavouritedStatuses,
|
FavouritedStatuses,
|
||||||
BookmarkedStatuses,
|
BookmarkedStatuses,
|
||||||
|
FollowedTags,
|
||||||
ListTimeline,
|
ListTimeline,
|
||||||
Blocks,
|
Blocks,
|
||||||
DomainBlocks,
|
DomainBlocks,
|
||||||
|
@ -216,6 +217,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
||||||
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
|
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
|
||||||
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
||||||
<WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
|
<WrappedRoute path='/domain_blocks' component={DomainBlocks} 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} />
|
||||||
|
|
||||||
|
|
|
@ -90,6 +90,10 @@ export function FavouritedStatuses () {
|
||||||
return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
|
return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FollowedTags () {
|
||||||
|
return import(/* webpackChunkName: "features/followed_tags" */'../../followed_tags');
|
||||||
|
}
|
||||||
|
|
||||||
export function BookmarkedStatuses () {
|
export function BookmarkedStatuses () {
|
||||||
return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
|
return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
|
||||||
}
|
}
|
||||||
|
|
|
@ -1391,6 +1391,10 @@
|
||||||
"defaultMessage": "Lists",
|
"defaultMessage": "Lists",
|
||||||
"id": "navigation_bar.lists"
|
"id": "navigation_bar.lists"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"defaultMessage": "Followed hashtags",
|
||||||
|
"id": "navigation_bar.followed_tags"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"defaultMessage": "Blocked users",
|
"defaultMessage": "Blocked users",
|
||||||
"id": "navigation_bar.blocks"
|
"id": "navigation_bar.blocks"
|
||||||
|
|
|
@ -379,6 +379,7 @@
|
||||||
"navigation_bar.favourites": "Favourites",
|
"navigation_bar.favourites": "Favourites",
|
||||||
"navigation_bar.filters": "Muted words",
|
"navigation_bar.filters": "Muted words",
|
||||||
"navigation_bar.follow_requests": "Follow requests",
|
"navigation_bar.follow_requests": "Follow requests",
|
||||||
|
"navigation_bar.followed_tags": "Followed hashtags",
|
||||||
"navigation_bar.follows_and_followers": "Follows and followers",
|
"navigation_bar.follows_and_followers": "Follows and followers",
|
||||||
"navigation_bar.lists": "Lists",
|
"navigation_bar.lists": "Lists",
|
||||||
"navigation_bar.logout": "Logout",
|
"navigation_bar.logout": "Logout",
|
||||||
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
import {
|
||||||
|
FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||||
|
FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||||
|
FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||||
|
FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||||
|
FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||||
|
FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||||
|
} from 'mastodon/actions/tags';
|
||||||
|
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||||
|
|
||||||
|
const initialState = ImmutableMap({
|
||||||
|
items: ImmutableList(),
|
||||||
|
isLoading: false,
|
||||||
|
next: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function followed_tags(state = initialState, action) {
|
||||||
|
switch(action.type) {
|
||||||
|
case FOLLOWED_HASHTAGS_FETCH_REQUEST:
|
||||||
|
return state.set('isLoading', true);
|
||||||
|
case FOLLOWED_HASHTAGS_FETCH_SUCCESS:
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.set('items', fromJS(action.followed_tags));
|
||||||
|
map.set('isLoading', false);
|
||||||
|
map.set('next', action.next);
|
||||||
|
});
|
||||||
|
case FOLLOWED_HASHTAGS_FETCH_FAIL:
|
||||||
|
return state.set('isLoading', false);
|
||||||
|
case FOLLOWED_HASHTAGS_EXPAND_REQUEST:
|
||||||
|
return state.set('isLoading', true);
|
||||||
|
case FOLLOWED_HASHTAGS_EXPAND_SUCCESS:
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.update('items', set => set.concat(fromJS(action.followed_tags)));
|
||||||
|
map.set('isLoading', false);
|
||||||
|
map.set('next', action.next);
|
||||||
|
});
|
||||||
|
case FOLLOWED_HASHTAGS_EXPAND_FAIL:
|
||||||
|
return state.set('isLoading', false);
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
|
@ -40,6 +40,7 @@ import picture_in_picture from './picture_in_picture';
|
||||||
import accounts_map from './accounts_map';
|
import accounts_map from './accounts_map';
|
||||||
import history from './history';
|
import history from './history';
|
||||||
import tags from './tags';
|
import tags from './tags';
|
||||||
|
import followed_tags from './followed_tags';
|
||||||
|
|
||||||
const reducers = {
|
const reducers = {
|
||||||
announcements,
|
announcements,
|
||||||
|
@ -83,6 +84,7 @@ const reducers = {
|
||||||
picture_in_picture,
|
picture_in_picture,
|
||||||
history,
|
history,
|
||||||
tags,
|
tags,
|
||||||
|
followed_tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default combineReducers(reducers);
|
export default combineReducers(reducers);
|
||||||
|
|
|
@ -27,6 +27,7 @@ Rails.application.routes.draw do
|
||||||
/blocks
|
/blocks
|
||||||
/domain_blocks
|
/domain_blocks
|
||||||
/mutes
|
/mutes
|
||||||
|
/followed_tags
|
||||||
/statuses/(*any)
|
/statuses/(*any)
|
||||||
).freeze
|
).freeze
|
||||||
|
|
||||||
|
|
Reference in New Issue