From be280f10c56c96ece233ba4ace1358b10580c451 Mon Sep 17 00:00:00 2001 From: Connor Shea Date: Wed, 18 Jan 2023 08:44:33 -0700 Subject: [PATCH] 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 * Fixes and resolve some other feedback. * Use set/update instead of setIn/updateIn. Co-authored-by: Claire --- app/javascript/mastodon/actions/tags.js | 82 ++++++++++++++++- .../features/account/components/header.js | 2 + .../features/compose/components/action_bar.js | 2 + .../mastodon/features/followed_tags/index.js | 89 +++++++++++++++++++ app/javascript/mastodon/features/ui/index.js | 2 + .../features/ui/util/async-components.js | 4 + .../mastodon/locales/defaultMessages.json | 6 +- app/javascript/mastodon/locales/en.json | 1 + .../mastodon/reducers/followed_tags.js | 42 +++++++++ app/javascript/mastodon/reducers/index.js | 2 + config/routes.rb | 1 + 11 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 app/javascript/mastodon/features/followed_tags/index.js create mode 100644 app/javascript/mastodon/reducers/followed_tags.js diff --git a/app/javascript/mastodon/actions/tags.js b/app/javascript/mastodon/actions/tags.js index 37e79d4cba..08a08cda31 100644 --- a/app/javascript/mastodon/actions/tags.js +++ b/app/javascript/mastodon/actions/tags.js @@ -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)); diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index f6004d1c4b..46fb89f2fa 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -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' }); diff --git a/app/javascript/mastodon/features/compose/components/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js index ceed928bf5..90c85321e5 100644 --- a/app/javascript/mastodon/features/compose/components/action_bar.js +++ b/app/javascript/mastodon/features/compose/components/action_bar.js @@ -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' }); diff --git a/app/javascript/mastodon/features/followed_tags/index.js b/app/javascript/mastodon/features/followed_tags/index.js new file mode 100644 index 0000000000..0a62ca76d0 --- /dev/null +++ b/app/javascript/mastodon/features/followed_tags/index.js @@ -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 = ; + + return ( + + + + + {hashtags.map((hashtag) => ( + day.get('uses')).toArray()} + /> + ))} + + + + + + + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 3fbe03fdf5..78dc9ea40b 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -42,6 +42,7 @@ import { FollowRequests, FavouritedStatuses, BookmarkedStatuses, + FollowedTags, ListTimeline, Blocks, DomainBlocks, @@ -216,6 +217,7 @@ class SwitchingColumnsArea extends React.PureComponent { + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 6046578de4..1cf07f6453 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -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'); } diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 3ed438fb81..210c91ad4b 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -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" } -] \ No newline at end of file +] diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 0240bf2e6d..992996dfb0 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.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", diff --git a/app/javascript/mastodon/reducers/followed_tags.js b/app/javascript/mastodon/reducers/followed_tags.js new file mode 100644 index 0000000000..f50ee6aa33 --- /dev/null +++ b/app/javascript/mastodon/reducers/followed_tags.js @@ -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; + } +}; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index bccdc18655..69771ad1b3 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -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); diff --git a/config/routes.rb b/config/routes.rb index 0bee2f639b..319f0c7d15 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,6 +27,7 @@ Rails.application.routes.draw do /blocks /domain_blocks /mutes + /followed_tags /statuses/(*any) ).freeze