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:
		
							parent
							
								
									3970a6f433
								
							
						
					
					
						commit
						30e895299c
					
				
					 11 changed files with 231 additions and 2 deletions
				
			
		|  | @ -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 | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue