[Glitch] Add ability to follow hashtags in web UI
This commit is contained in:
		
							parent
							
								
									bcb958c264
								
							
						
					
					
						commit
						991349e6e2
					
				
					 4 changed files with 185 additions and 16 deletions
				
			
		
							
								
								
									
										92
									
								
								app/javascript/flavours/glitch/actions/tags.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								app/javascript/flavours/glitch/actions/tags.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,92 @@ | ||||||
|  | import api from 'flavours/glitch/util/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 HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST'; | ||||||
|  | export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS'; | ||||||
|  | export const HASHTAG_FOLLOW_FAIL    = 'HASHTAG_FOLLOW_FAIL'; | ||||||
|  | 
 | ||||||
|  | export const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST'; | ||||||
|  | export const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS'; | ||||||
|  | export const HASHTAG_UNFOLLOW_FAIL    = 'HASHTAG_UNFOLLOW_FAIL'; | ||||||
|  | 
 | ||||||
|  | export const fetchHashtag = name => (dispatch, getState) => { | ||||||
|  |   dispatch(fetchHashtagRequest()); | ||||||
|  | 
 | ||||||
|  |   api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => { | ||||||
|  |     dispatch(fetchHashtagSuccess(name, data)); | ||||||
|  |   }).catch(err => { | ||||||
|  |     dispatch(fetchHashtagFail(err)); | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const fetchHashtagRequest = () => ({ | ||||||
|  |   type: HASHTAG_FETCH_REQUEST, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export const fetchHashtagSuccess = (name, tag) => ({ | ||||||
|  |   type: HASHTAG_FETCH_SUCCESS, | ||||||
|  |   name, | ||||||
|  |   tag, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export const fetchHashtagFail = error => ({ | ||||||
|  |   type: HASHTAG_FETCH_FAIL, | ||||||
|  |   error, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export const followHashtag = name => (dispatch, getState) => { | ||||||
|  |   dispatch(followHashtagRequest(name)); | ||||||
|  | 
 | ||||||
|  |   api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => { | ||||||
|  |     dispatch(followHashtagSuccess(name, data)); | ||||||
|  |   }).catch(err => { | ||||||
|  |     dispatch(followHashtagFail(name, err)); | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const followHashtagRequest = name => ({ | ||||||
|  |   type: HASHTAG_FOLLOW_REQUEST, | ||||||
|  |   name, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export const followHashtagSuccess = (name, tag) => ({ | ||||||
|  |   type: HASHTAG_FOLLOW_SUCCESS, | ||||||
|  |   name, | ||||||
|  |   tag, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export const followHashtagFail = (name, error) => ({ | ||||||
|  |   type: HASHTAG_FOLLOW_FAIL, | ||||||
|  |   name, | ||||||
|  |   error, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export const unfollowHashtag = name => (dispatch, getState) => { | ||||||
|  |   dispatch(unfollowHashtagRequest(name)); | ||||||
|  | 
 | ||||||
|  |   api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => { | ||||||
|  |     dispatch(unfollowHashtagSuccess(name, data)); | ||||||
|  |   }).catch(err => { | ||||||
|  |     dispatch(unfollowHashtagFail(name, err)); | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const unfollowHashtagRequest = name => ({ | ||||||
|  |   type: HASHTAG_FETCH_REQUEST, | ||||||
|  |   name, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export const unfollowHashtagSuccess = (name, tag) => ({ | ||||||
|  |   type: HASHTAG_FETCH_SUCCESS, | ||||||
|  |   name, | ||||||
|  |   tag, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export const unfollowHashtagFail = (name, error) => ({ | ||||||
|  |   type: HASHTAG_FETCH_FAIL, | ||||||
|  |   name, | ||||||
|  |   error, | ||||||
|  | }); | ||||||
|  | @ -4,18 +4,29 @@ import PropTypes from 'prop-types'; | ||||||
| import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; | import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; | ||||||
| import Column from 'flavours/glitch/components/column'; | import Column from 'flavours/glitch/components/column'; | ||||||
| import ColumnHeader from 'flavours/glitch/components/column_header'; | import ColumnHeader from 'flavours/glitch/components/column_header'; | ||||||
|  | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import ColumnSettingsContainer from './containers/column_settings_container'; | import ColumnSettingsContainer from './containers/column_settings_container'; | ||||||
| import { expandHashtagTimeline, clearTimeline } from 'flavours/glitch/actions/timelines'; | import { expandHashtagTimeline, clearTimeline } from 'flavours/glitch/actions/timelines'; | ||||||
| import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; | import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; | ||||||
| import { FormattedMessage } from 'react-intl'; |  | ||||||
| import { connectHashtagStream } from 'flavours/glitch/actions/streaming'; | import { connectHashtagStream } from 'flavours/glitch/actions/streaming'; | ||||||
|  | import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; | ||||||
| import { isEqual } from 'lodash'; | import { isEqual } from 'lodash'; | ||||||
|  | import { fetchHashtag, followHashtag, unfollowHashtag } from 'flavours/glitch/actions/tags'; | ||||||
|  | import Icon from 'flavours/glitch/components/icon'; | ||||||
|  | import classNames from 'classnames'; | ||||||
|  | 
 | ||||||
|  | const messages = defineMessages({ | ||||||
|  |   followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, | ||||||
|  |   unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' }, | ||||||
|  | }); | ||||||
| 
 | 
 | ||||||
| const mapStateToProps = (state, props) => ({ | const mapStateToProps = (state, props) => ({ | ||||||
|   hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0, |   hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0, | ||||||
|  |   tag: state.getIn(['tags', props.params.id]), | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| export default @connect(mapStateToProps) | export default @connect(mapStateToProps) | ||||||
|  | @injectIntl | ||||||
| class HashtagTimeline extends React.PureComponent { | class HashtagTimeline extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|   disconnects = []; |   disconnects = []; | ||||||
|  | @ -25,7 +36,9 @@ class HashtagTimeline extends React.PureComponent { | ||||||
|     columnId: PropTypes.string, |     columnId: PropTypes.string, | ||||||
|     dispatch: PropTypes.func.isRequired, |     dispatch: PropTypes.func.isRequired, | ||||||
|     hasUnread: PropTypes.bool, |     hasUnread: PropTypes.bool, | ||||||
|  |     tag: ImmutablePropTypes.map, | ||||||
|     multiColumn: PropTypes.bool, |     multiColumn: PropTypes.bool, | ||||||
|  |     intl: PropTypes.object, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   handlePin = () => { |   handlePin = () => { | ||||||
|  | @ -39,7 +52,8 @@ class HashtagTimeline extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   title = () => { |   title = () => { | ||||||
|     let title = [this.props.params.id]; |     const { id } = this.props.params; | ||||||
|  |     const title  = [id]; | ||||||
| 
 | 
 | ||||||
|     if (this.additionalFor('any')) { |     if (this.additionalFor('any')) { | ||||||
|       title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any'  values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />); |       title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any'  values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />); | ||||||
|  | @ -95,23 +109,34 @@ class HashtagTimeline extends React.PureComponent { | ||||||
|     this.disconnects = []; |     this.disconnects = []; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentDidMount () { |   _unload () { | ||||||
|  |     const { dispatch } = this.props; | ||||||
|  |     const { id, local } = this.props.params; | ||||||
|  | 
 | ||||||
|  |     this._unsubscribe(); | ||||||
|  |     dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   _load() { | ||||||
|     const { dispatch } = this.props; |     const { dispatch } = this.props; | ||||||
|     const { id, tags, local } = this.props.params; |     const { id, tags, local } = this.props.params; | ||||||
| 
 | 
 | ||||||
|     this._subscribe(dispatch, id, tags, local); |     this._subscribe(dispatch, id, tags, local); | ||||||
|     dispatch(expandHashtagTimeline(id, { tags, local })); |     dispatch(expandHashtagTimeline(id, { tags, local })); | ||||||
|  |     dispatch(fetchHashtag(id)); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentWillReceiveProps (nextProps) { |   componentDidMount () { | ||||||
|     const { dispatch, params } = this.props; |     this._load(); | ||||||
|     const { id, tags, local } = nextProps.params; |   } | ||||||
|  | 
 | ||||||
|  |   componentDidUpdate (prevProps) { | ||||||
|  |     const { params } = this.props; | ||||||
|  |     const { id, tags, local } = prevProps.params; | ||||||
| 
 | 
 | ||||||
|     if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) { |     if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) { | ||||||
|       this._unsubscribe(); |       this._unload(); | ||||||
|       this._subscribe(dispatch, id, tags, local); |       this._load(); | ||||||
|       dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`)); |  | ||||||
|       dispatch(expandHashtagTimeline(id, { tags, local })); |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -124,17 +149,42 @@ class HashtagTimeline extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadMore = maxId => { |   handleLoadMore = maxId => { | ||||||
|     const { id, tags, local } = this.props.params; |     const { dispatch, params } = this.props; | ||||||
|     this.props.dispatch(expandHashtagTimeline(id, { maxId, tags, local })); |     const { id, tags, local }  = params; | ||||||
|  | 
 | ||||||
|  |     dispatch(expandHashtagTimeline(id, { maxId, tags, local })); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   handleFollow = () => { | ||||||
|  |     const { dispatch, params, tag } = this.props; | ||||||
|  |     const { id } = params; | ||||||
|  | 
 | ||||||
|  |     if (tag.get('following')) { | ||||||
|  |       dispatch(unfollowHashtag(id)); | ||||||
|  |     } else { | ||||||
|  |       dispatch(followHashtag(id)); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { hasUnread, columnId, multiColumn } = this.props; |     const { hasUnread, columnId, multiColumn, tag, intl } = this.props; | ||||||
|     const { id,  local } = this.props.params; |     const { id, local } = this.props.params; | ||||||
|     const pinned = !!columnId; |     const pinned = !!columnId; | ||||||
| 
 | 
 | ||||||
|  |     let followButton; | ||||||
|  | 
 | ||||||
|  |     if (tag) { | ||||||
|  |       const following = tag.get('following'); | ||||||
|  | 
 | ||||||
|  |       followButton = ( | ||||||
|  |         <button className={classNames('column-header__button')} onClick={this.handleFollow} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-pressed={following ? 'true' : 'false'}> | ||||||
|  |           <Icon id={following ? 'user-times' : 'user-plus'} fixedWidth className='column-header__icon' /> | ||||||
|  |         </button> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     return ( |     return ( | ||||||
|       <Column ref={this.setRef} name='hashtag' label={`#${id}`}> |       <Column bindToDocument={!multiColumn} ref={this.setRef} label={`#${id}`}> | ||||||
|         <ColumnHeader |         <ColumnHeader | ||||||
|           icon='hashtag' |           icon='hashtag' | ||||||
|           active={hasUnread} |           active={hasUnread} | ||||||
|  | @ -144,8 +194,8 @@ class HashtagTimeline extends React.PureComponent { | ||||||
|           onClick={this.handleHeaderClick} |           onClick={this.handleHeaderClick} | ||||||
|           pinned={pinned} |           pinned={pinned} | ||||||
|           multiColumn={multiColumn} |           multiColumn={multiColumn} | ||||||
|  |           extraButton={followButton} | ||||||
|           showBackButton |           showBackButton | ||||||
|           bindToDocument={!multiColumn} |  | ||||||
|         > |         > | ||||||
|           {columnId && <ColumnSettingsContainer columnId={columnId} />} |           {columnId && <ColumnSettingsContainer columnId={columnId} />} | ||||||
|         </ColumnHeader> |         </ColumnHeader> | ||||||
|  |  | ||||||
|  | @ -41,6 +41,7 @@ import account_notes from './account_notes'; | ||||||
| import picture_in_picture from './picture_in_picture'; | 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'; | ||||||
| 
 | 
 | ||||||
| const reducers = { | const reducers = { | ||||||
|   announcements, |   announcements, | ||||||
|  | @ -85,6 +86,7 @@ const reducers = { | ||||||
|   account_notes, |   account_notes, | ||||||
|   picture_in_picture, |   picture_in_picture, | ||||||
|   history, |   history, | ||||||
|  |   tags, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default combineReducers(reducers); | export default combineReducers(reducers); | ||||||
|  |  | ||||||
							
								
								
									
										25
									
								
								app/javascript/flavours/glitch/reducers/tags.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/javascript/flavours/glitch/reducers/tags.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | ||||||
|  | import { | ||||||
|  |   HASHTAG_FETCH_SUCCESS, | ||||||
|  |   HASHTAG_FOLLOW_REQUEST, | ||||||
|  |   HASHTAG_FOLLOW_FAIL, | ||||||
|  |   HASHTAG_UNFOLLOW_REQUEST, | ||||||
|  |   HASHTAG_UNFOLLOW_FAIL, | ||||||
|  | } from 'mastodon/actions/tags'; | ||||||
|  | import { Map as ImmutableMap, fromJS } from 'immutable'; | ||||||
|  | 
 | ||||||
|  | const initialState = ImmutableMap(); | ||||||
|  | 
 | ||||||
|  | export default function tags(state = initialState, action) { | ||||||
|  |   switch(action.type) { | ||||||
|  |   case HASHTAG_FETCH_SUCCESS: | ||||||
|  |     return state.set(action.name, fromJS(action.tag)); | ||||||
|  |   case HASHTAG_FOLLOW_REQUEST: | ||||||
|  |   case HASHTAG_UNFOLLOW_FAIL: | ||||||
|  |     return state.setIn([action.name, 'following'], true); | ||||||
|  |   case HASHTAG_FOLLOW_FAIL: | ||||||
|  |   case HASHTAG_UNFOLLOW_REQUEST: | ||||||
|  |     return state.setIn([action.name, 'following'], false); | ||||||
|  |   default: | ||||||
|  |     return state; | ||||||
|  |   } | ||||||
|  | }; | ||||||
		Loading…
	
		Reference in a new issue