From 9608e720a4e5f977c0e2db0de7b264574b0ddeac Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 22 Jul 2022 15:52:06 +0200 Subject: [PATCH 1/2] Add ability to follow hashtags in web UI (#18862) --- app/javascript/mastodon/actions/tags.js | 92 +++++++++++++++++++ .../features/hashtag_timeline/index.js | 87 ++++++++++++++---- app/javascript/mastodon/reducers/index.js | 2 + app/javascript/mastodon/reducers/tags.js | 25 +++++ 4 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 app/javascript/mastodon/actions/tags.js create mode 100644 app/javascript/mastodon/reducers/tags.js diff --git a/app/javascript/mastodon/actions/tags.js b/app/javascript/mastodon/actions/tags.js new file mode 100644 index 000000000..216e5b541 --- /dev/null +++ b/app/javascript/mastodon/actions/tags.js @@ -0,0 +1,92 @@ +import api 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 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, +}); diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index 6a808eb30..dc8a61640 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -1,21 +1,32 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; import StatusListContainer from '../ui/containers/status_list_container'; -import Column from '../../components/column'; -import ColumnHeader from '../../components/column_header'; +import Column from 'mastodon/components/column'; +import ColumnHeader from 'mastodon/components/column_header'; import ColumnSettingsContainer from './containers/column_settings_container'; -import { expandHashtagTimeline, clearTimeline } from '../../actions/timelines'; -import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; -import { FormattedMessage } from 'react-intl'; -import { connectHashtagStream } from '../../actions/streaming'; +import { expandHashtagTimeline, clearTimeline } from 'mastodon/actions/timelines'; +import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; +import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; +import { connectHashtagStream } from 'mastodon/actions/streaming'; import { isEqual } from 'lodash'; +import { fetchHashtag, followHashtag, unfollowHashtag } from 'mastodon/actions/tags'; +import Icon from 'mastodon/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) => ({ 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) +@injectIntl class HashtagTimeline extends React.PureComponent { disconnects = []; @@ -25,7 +36,9 @@ class HashtagTimeline extends React.PureComponent { columnId: PropTypes.string, dispatch: PropTypes.func.isRequired, hasUnread: PropTypes.bool, + tag: ImmutablePropTypes.map, multiColumn: PropTypes.bool, + intl: PropTypes.object, }; handlePin = () => { @@ -39,7 +52,8 @@ class HashtagTimeline extends React.PureComponent { } title = () => { - let title = [this.props.params.id]; + const { id } = this.props.params; + const title = [id]; if (this.additionalFor('any')) { title.push(' ', ); @@ -95,23 +109,34 @@ class HashtagTimeline extends React.PureComponent { 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 { id, tags, local } = this.props.params; this._subscribe(dispatch, id, tags, local); dispatch(expandHashtagTimeline(id, { tags, local })); + dispatch(fetchHashtag(id)); } - componentWillReceiveProps (nextProps) { - const { dispatch, params } = this.props; - const { id, tags, local } = nextProps.params; + componentDidMount () { + this._load(); + } + + componentDidUpdate (prevProps) { + const { params } = this.props; + const { id, tags, local } = prevProps.params; if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) { - this._unsubscribe(); - this._subscribe(dispatch, id, tags, local); - dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`)); - dispatch(expandHashtagTimeline(id, { tags, local })); + this._unload(); + this._load(); } } @@ -124,15 +149,40 @@ class HashtagTimeline extends React.PureComponent { } handleLoadMore = maxId => { - const { id, tags, local } = this.props.params; - this.props.dispatch(expandHashtagTimeline(id, { maxId, tags, local })); + const { dispatch, params } = this.props; + 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 () { - const { hasUnread, columnId, multiColumn } = this.props; + const { hasUnread, columnId, multiColumn, tag, intl } = this.props; const { id, local } = this.props.params; const pinned = !!columnId; + let followButton; + + if (tag) { + const following = tag.get('following'); + + followButton = ( + + ); + } + return ( {columnId && } diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 0219d8a5e..d3d0303df 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -39,6 +39,7 @@ import markers from './markers'; import picture_in_picture from './picture_in_picture'; import accounts_map from './accounts_map'; import history from './history'; +import tags from './tags'; const reducers = { announcements, @@ -81,6 +82,7 @@ const reducers = { markers, picture_in_picture, history, + tags, }; export default combineReducers(reducers); diff --git a/app/javascript/mastodon/reducers/tags.js b/app/javascript/mastodon/reducers/tags.js new file mode 100644 index 000000000..d24098e39 --- /dev/null +++ b/app/javascript/mastodon/reducers/tags.js @@ -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; + } +}; From 991349e6e27f90b671c7892cfbfc74c5a1300f8b Mon Sep 17 00:00:00 2001 From: prplecake Date: Sat, 30 Jul 2022 20:00:00 -0500 Subject: [PATCH 2/2] [Glitch] Add ability to follow hashtags in web UI --- .../flavours/glitch/actions/tags.js | 92 +++++++++++++++++++ .../glitch/features/hashtag_timeline/index.js | 82 +++++++++++++---- .../flavours/glitch/reducers/index.js | 2 + .../flavours/glitch/reducers/tags.js | 25 +++++ 4 files changed, 185 insertions(+), 16 deletions(-) create mode 100644 app/javascript/flavours/glitch/actions/tags.js create mode 100644 app/javascript/flavours/glitch/reducers/tags.js diff --git a/app/javascript/flavours/glitch/actions/tags.js b/app/javascript/flavours/glitch/actions/tags.js new file mode 100644 index 000000000..3933da8ba --- /dev/null +++ b/app/javascript/flavours/glitch/actions/tags.js @@ -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, +}); diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js index 48e52e4cd..87a52b269 100644 --- a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js @@ -4,18 +4,29 @@ import PropTypes from 'prop-types'; import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; import Column from 'flavours/glitch/components/column'; import ColumnHeader from 'flavours/glitch/components/column_header'; +import ImmutablePropTypes from 'react-immutable-proptypes'; import ColumnSettingsContainer from './containers/column_settings_container'; import { expandHashtagTimeline, clearTimeline } from 'flavours/glitch/actions/timelines'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; -import { FormattedMessage } from 'react-intl'; import { connectHashtagStream } from 'flavours/glitch/actions/streaming'; +import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; 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) => ({ 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) +@injectIntl class HashtagTimeline extends React.PureComponent { disconnects = []; @@ -25,7 +36,9 @@ class HashtagTimeline extends React.PureComponent { columnId: PropTypes.string, dispatch: PropTypes.func.isRequired, hasUnread: PropTypes.bool, + tag: ImmutablePropTypes.map, multiColumn: PropTypes.bool, + intl: PropTypes.object, }; handlePin = () => { @@ -39,7 +52,8 @@ class HashtagTimeline extends React.PureComponent { } title = () => { - let title = [this.props.params.id]; + const { id } = this.props.params; + const title = [id]; if (this.additionalFor('any')) { title.push(' ', ); @@ -95,23 +109,34 @@ class HashtagTimeline extends React.PureComponent { 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 { id, tags, local } = this.props.params; this._subscribe(dispatch, id, tags, local); dispatch(expandHashtagTimeline(id, { tags, local })); + dispatch(fetchHashtag(id)); } - componentWillReceiveProps (nextProps) { - const { dispatch, params } = this.props; - const { id, tags, local } = nextProps.params; + componentDidMount () { + this._load(); + } + + componentDidUpdate (prevProps) { + const { params } = this.props; + const { id, tags, local } = prevProps.params; if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) { - this._unsubscribe(); - this._subscribe(dispatch, id, tags, local); - dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`)); - dispatch(expandHashtagTimeline(id, { tags, local })); + this._unload(); + this._load(); } } @@ -124,17 +149,42 @@ class HashtagTimeline extends React.PureComponent { } handleLoadMore = maxId => { - const { id, tags, local } = this.props.params; - this.props.dispatch(expandHashtagTimeline(id, { maxId, tags, local })); + const { dispatch, params } = this.props; + 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 () { - const { hasUnread, columnId, multiColumn } = this.props; - const { id, local } = this.props.params; + const { hasUnread, columnId, multiColumn, tag, intl } = this.props; + const { id, local } = this.props.params; const pinned = !!columnId; + let followButton; + + if (tag) { + const following = tag.get('following'); + + followButton = ( + + ); + } + return ( - + {columnId && } diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js index b8aad9fad..991b4aa79 100644 --- a/app/javascript/flavours/glitch/reducers/index.js +++ b/app/javascript/flavours/glitch/reducers/index.js @@ -41,6 +41,7 @@ import account_notes from './account_notes'; import picture_in_picture from './picture_in_picture'; import accounts_map from './accounts_map'; import history from './history'; +import tags from './tags'; const reducers = { announcements, @@ -85,6 +86,7 @@ const reducers = { account_notes, picture_in_picture, history, + tags, }; export default combineReducers(reducers); diff --git a/app/javascript/flavours/glitch/reducers/tags.js b/app/javascript/flavours/glitch/reducers/tags.js new file mode 100644 index 000000000..d24098e39 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/tags.js @@ -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; + } +};