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>
This commit is contained in:
		@@ -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_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
 | 
			
		||||
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_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
 | 
			
		||||
export const HASHTAG_FOLLOW_FAIL    = 'HASHTAG_FOLLOW_FAIL';
 | 
			
		||||
@@ -37,6 +45,78 @@ export const fetchHashtagFail = 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) => {
 | 
			
		||||
  dispatch(followHashtagRequest(name));
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -46,6 +46,7 @@ const messages = defineMessages({
 | 
			
		||||
  follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
 | 
			
		||||
  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
 | 
			
		||||
  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' },
 | 
			
		||||
  domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
 | 
			
		||||
  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.favourites), to: '/favourites' });
 | 
			
		||||
      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({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
 | 
			
		||||
      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' },
 | 
			
		||||
  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
 | 
			
		||||
  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' },
 | 
			
		||||
  domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
 | 
			
		||||
  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.bookmarks), to: '/bookmarks' });
 | 
			
		||||
    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({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
 | 
			
		||||
    menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										89
									
								
								app/javascript/mastodon/features/followed_tags/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								app/javascript/mastodon/features/followed_tags/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -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,
 | 
			
		||||
  FavouritedStatuses,
 | 
			
		||||
  BookmarkedStatuses,
 | 
			
		||||
  FollowedTags,
 | 
			
		||||
  ListTimeline,
 | 
			
		||||
  Blocks,
 | 
			
		||||
  DomainBlocks,
 | 
			
		||||
@@ -216,6 +217,7 @@ class SwitchingColumnsArea extends React.PureComponent {
 | 
			
		||||
          <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
 | 
			
		||||
          <WrappedRoute path='/blocks' component={Blocks} 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='/lists' component={Lists} content={children} />
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -90,6 +90,10 @@ export function FavouritedStatuses () {
 | 
			
		||||
  return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function FollowedTags () {
 | 
			
		||||
  return import(/* webpackChunkName: "features/followed_tags" */'../../followed_tags');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function BookmarkedStatuses () {
 | 
			
		||||
  return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1391,6 +1391,10 @@
 | 
			
		||||
        "defaultMessage": "Lists",
 | 
			
		||||
        "id": "navigation_bar.lists"
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        "defaultMessage": "Followed hashtags",
 | 
			
		||||
        "id": "navigation_bar.followed_tags"
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        "defaultMessage": "Blocked users",
 | 
			
		||||
        "id": "navigation_bar.blocks"
 | 
			
		||||
@@ -4310,4 +4314,4 @@
 | 
			
		||||
    ],
 | 
			
		||||
    "path": "app/javascript/mastodon/features/video/index.json"
 | 
			
		||||
  }
 | 
			
		||||
]
 | 
			
		||||
]
 | 
			
		||||
 
 | 
			
		||||
@@ -379,6 +379,7 @@
 | 
			
		||||
  "navigation_bar.favourites": "Favourites",
 | 
			
		||||
  "navigation_bar.filters": "Muted words",
 | 
			
		||||
  "navigation_bar.follow_requests": "Follow requests",
 | 
			
		||||
  "navigation_bar.followed_tags": "Followed hashtags",
 | 
			
		||||
  "navigation_bar.follows_and_followers": "Follows and followers",
 | 
			
		||||
  "navigation_bar.lists": "Lists",
 | 
			
		||||
  "navigation_bar.logout": "Logout",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										42
									
								
								app/javascript/mastodon/reducers/followed_tags.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								app/javascript/mastodon/reducers/followed_tags.js
									
									
									
									
									
										Normal file
									
								
							@@ -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 history from './history';
 | 
			
		||||
import tags from './tags';
 | 
			
		||||
import followed_tags from './followed_tags';
 | 
			
		||||
 | 
			
		||||
const reducers = {
 | 
			
		||||
  announcements,
 | 
			
		||||
@@ -83,6 +84,7 @@ const reducers = {
 | 
			
		||||
  picture_in_picture,
 | 
			
		||||
  history,
 | 
			
		||||
  tags,
 | 
			
		||||
  followed_tags,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default combineReducers(reducers);
 | 
			
		||||
 
 | 
			
		||||
@@ -27,6 +27,7 @@ Rails.application.routes.draw do
 | 
			
		||||
    /blocks
 | 
			
		||||
    /domain_blocks
 | 
			
		||||
    /mutes
 | 
			
		||||
    /followed_tags
 | 
			
		||||
    /statuses/(*any)
 | 
			
		||||
  ).freeze
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user