diff --git a/app/javascript/mastodon/actions/tags.js b/app/javascript/mastodon/actions/tags.js index d18d7e514f..6e0c95288a 100644 --- a/app/javascript/mastodon/actions/tags.js +++ b/app/javascript/mastodon/actions/tags.js @@ -1,9 +1,5 @@ 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'; @@ -12,39 +8,6 @@ export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUES 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'; - -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) => { - dispatch(fetchHashtagRequest()); - - api().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 fetchFollowedHashtags = () => (dispatch) => { dispatch(fetchFollowedHashtagsRequest()); @@ -116,57 +79,3 @@ export function expandFollowedHashtagsFail(error) { error, }; } - -export const followHashtag = name => (dispatch) => { - dispatch(followHashtagRequest(name)); - - api().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) => { - dispatch(unfollowHashtagRequest(name)); - - api().post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => { - dispatch(unfollowHashtagSuccess(name, data)); - }).catch(err => { - dispatch(unfollowHashtagFail(name, err)); - }); -}; - -export const unfollowHashtagRequest = name => ({ - type: HASHTAG_UNFOLLOW_REQUEST, - name, -}); - -export const unfollowHashtagSuccess = (name, tag) => ({ - type: HASHTAG_UNFOLLOW_SUCCESS, - name, - tag, -}); - -export const unfollowHashtagFail = (name, error) => ({ - type: HASHTAG_UNFOLLOW_FAIL, - name, - error, -}); diff --git a/app/javascript/mastodon/actions/tags_typed.ts b/app/javascript/mastodon/actions/tags_typed.ts new file mode 100644 index 0000000000..6dca32fd84 --- /dev/null +++ b/app/javascript/mastodon/actions/tags_typed.ts @@ -0,0 +1,17 @@ +import { apiGetTag, apiFollowTag, apiUnfollowTag } from 'mastodon/api/tags'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +export const fetchHashtag = createDataLoadingThunk( + 'tags/fetch', + ({ tagId }: { tagId: string }) => apiGetTag(tagId), +); + +export const followHashtag = createDataLoadingThunk( + 'tags/follow', + ({ tagId }: { tagId: string }) => apiFollowTag(tagId), +); + +export const unfollowHashtag = createDataLoadingThunk( + 'tags/unfollow', + ({ tagId }: { tagId: string }) => apiUnfollowTag(tagId), +); diff --git a/app/javascript/mastodon/api/tags.ts b/app/javascript/mastodon/api/tags.ts new file mode 100644 index 0000000000..2cb802800c --- /dev/null +++ b/app/javascript/mastodon/api/tags.ts @@ -0,0 +1,11 @@ +import { apiRequestPost, apiRequestGet } from 'mastodon/api'; +import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; + +export const apiGetTag = (tagId: string) => + apiRequestGet(`v1/tags/${tagId}`); + +export const apiFollowTag = (tagId: string) => + apiRequestPost(`v1/tags/${tagId}/follow`); + +export const apiUnfollowTag = (tagId: string) => + apiRequestPost(`v1/tags/${tagId}/unfollow`); diff --git a/app/javascript/mastodon/api_types/tags.ts b/app/javascript/mastodon/api_types/tags.ts new file mode 100644 index 0000000000..0c16c8bd28 --- /dev/null +++ b/app/javascript/mastodon/api_types/tags.ts @@ -0,0 +1,13 @@ +interface ApiHistoryJSON { + day: string; + accounts: string; + uses: string; +} + +export interface ApiHashtagJSON { + id: string; + name: string; + url: string; + history: [ApiHistoryJSON, ...ApiHistoryJSON[]]; + following?: boolean; +} diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.jsx b/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.jsx deleted file mode 100644 index 415cf7bf10..0000000000 --- a/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.jsx +++ /dev/null @@ -1,94 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; - -import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; -import { Button } from 'mastodon/components/button'; -import { ShortNumber } from 'mastodon/components/short_number'; -import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; -import { withIdentity } from 'mastodon/identity_context'; -import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions'; - -const messages = defineMessages({ - followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, - unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' }, - adminModeration: { id: 'hashtag.admin_moderation', defaultMessage: 'Open moderation interface for #{name}' }, -}); - -const usesRenderer = (displayNumber, pluralReady) => ( - {displayNumber}, - }} - /> -); - -const peopleRenderer = (displayNumber, pluralReady) => ( - {displayNumber}, - }} - /> -); - -const usesTodayRenderer = (displayNumber, pluralReady) => ( - {displayNumber}, - }} - /> -); - -export const HashtagHeader = withIdentity(injectIntl(({ tag, intl, disabled, onClick, identity }) => { - if (!tag) { - return null; - } - - const { signedIn, permissions } = identity; - const menu = []; - - if (signedIn && (permissions & PERMISSION_MANAGE_TAXONOMIES) === PERMISSION_MANAGE_TAXONOMIES ) { - menu.push({ text: intl.formatMessage(messages.adminModeration, { name: tag.get("name") }), href: `/admin/tags/${tag.get('id')}` }); - } - - const [uses, people] = tag.get('history').reduce((arr, day) => [arr[0] + day.get('uses') * 1, arr[1] + day.get('accounts') * 1], [0, 0]); - const dividingCircle = {' · '}; - - return ( -
-
-

#{tag.get('name')}

-
- { menu.length > 0 && } -
-
- -
- - {dividingCircle} - - {dividingCircle} - -
-
- ); -})); - -HashtagHeader.propTypes = { - tag: ImmutablePropTypes.map, - disabled: PropTypes.bool, - onClick: PropTypes.func, - intl: PropTypes.object, -}; diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.tsx b/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.tsx new file mode 100644 index 0000000000..7372fe5284 --- /dev/null +++ b/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.tsx @@ -0,0 +1,188 @@ +import { useCallback, useMemo, useState, useEffect } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { isFulfilled } from '@reduxjs/toolkit'; + +import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; +import { + fetchHashtag, + followHashtag, + unfollowHashtag, +} from 'mastodon/actions/tags_typed'; +import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; +import { Button } from 'mastodon/components/button'; +import { ShortNumber } from 'mastodon/components/short_number'; +import DropdownMenu from 'mastodon/containers/dropdown_menu_container'; +import { useIdentity } from 'mastodon/identity_context'; +import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions'; +import { useAppDispatch } from 'mastodon/store'; + +const messages = defineMessages({ + followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, + unfollowHashtag: { + id: 'hashtag.unfollow', + defaultMessage: 'Unfollow hashtag', + }, + adminModeration: { + id: 'hashtag.admin_moderation', + defaultMessage: 'Open moderation interface for #{name}', + }, +}); + +const usesRenderer = (displayNumber: React.ReactNode, pluralReady: number) => ( + {displayNumber}, + }} + /> +); + +const peopleRenderer = ( + displayNumber: React.ReactNode, + pluralReady: number, +) => ( + {displayNumber}, + }} + /> +); + +const usesTodayRenderer = ( + displayNumber: React.ReactNode, + pluralReady: number, +) => ( + {displayNumber}, + }} + /> +); + +export const HashtagHeader: React.FC<{ + tagId: string; +}> = ({ tagId }) => { + const intl = useIntl(); + const { signedIn, permissions } = useIdentity(); + const dispatch = useAppDispatch(); + const [tag, setTag] = useState(); + + useEffect(() => { + void dispatch(fetchHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + setTag(result.payload); + } + + return ''; + }); + }, [dispatch, tagId, setTag]); + + const menu = useMemo(() => { + const tmp = []; + + if ( + tag && + signedIn && + (permissions & PERMISSION_MANAGE_TAXONOMIES) === + PERMISSION_MANAGE_TAXONOMIES + ) { + tmp.push({ + text: intl.formatMessage(messages.adminModeration, { name: tag.id }), + href: `/admin/tags/${tag.id}`, + }); + } + + return tmp; + }, [signedIn, permissions, intl, tag]); + + const handleFollow = useCallback(() => { + if (!signedIn || !tag) { + return; + } + + if (tag.following) { + setTag((hashtag) => hashtag && { ...hashtag, following: false }); + + void dispatch(unfollowHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + setTag(result.payload); + } + + return ''; + }); + } else { + setTag((hashtag) => hashtag && { ...hashtag, following: true }); + + void dispatch(followHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + setTag(result.payload); + } + + return ''; + }); + } + }, [dispatch, setTag, signedIn, tag, tagId]); + + if (!tag) { + return null; + } + + const [uses, people] = tag.history.reduce( + (arr, day) => [ + arr[0] + parseInt(day.uses), + arr[1] + parseInt(day.accounts), + ], + [0, 0], + ); + const dividingCircle = {' · '}; + + return ( +
+
+

#{tag.name}

+ +
+ {menu.length > 0 && ( + + )} + +
+
+ +
+ + {dividingCircle} + + {dividingCircle} + +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.jsx b/app/javascript/mastodon/features/hashtag_timeline/index.jsx index 42a668859e..ab3c32e6a7 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.jsx +++ b/app/javascript/mastodon/features/hashtag_timeline/index.jsx @@ -5,7 +5,6 @@ import { FormattedMessage } from 'react-intl'; import { Helmet } from 'react-helmet'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { isEqual } from 'lodash'; @@ -13,7 +12,6 @@ import { isEqual } from 'lodash'; import TagIcon from '@/material-icons/400-24px/tag.svg?react'; import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; import { connectHashtagStream } from 'mastodon/actions/streaming'; -import { fetchHashtag, followHashtag, unfollowHashtag } from 'mastodon/actions/tags'; import { expandHashtagTimeline, clearTimeline } from 'mastodon/actions/timelines'; import Column from 'mastodon/components/column'; import ColumnHeader from 'mastodon/components/column_header'; @@ -26,7 +24,6 @@ import ColumnSettingsContainer from './containers/column_settings_container'; 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]), }); class HashtagTimeline extends PureComponent { @@ -38,7 +35,6 @@ class HashtagTimeline extends PureComponent { columnId: PropTypes.string, dispatch: PropTypes.func.isRequired, hasUnread: PropTypes.bool, - tag: ImmutablePropTypes.map, multiColumn: PropTypes.bool, }; @@ -130,7 +126,6 @@ class HashtagTimeline extends PureComponent { this._subscribe(dispatch, id, tags, local); dispatch(expandHashtagTimeline(id, { tags, local })); - dispatch(fetchHashtag(id)); } componentDidMount () { @@ -162,27 +157,10 @@ class HashtagTimeline extends PureComponent { dispatch(expandHashtagTimeline(id, { maxId, tags, local })); }; - handleFollow = () => { - const { dispatch, params, tag } = this.props; - const { id } = params; - const { signedIn } = this.props.identity; - - if (!signedIn) { - return; - } - - if (tag.get('following')) { - dispatch(unfollowHashtag(id)); - } else { - dispatch(followHashtag(id)); - } - }; - render () { - const { hasUnread, columnId, multiColumn, tag } = this.props; + const { hasUnread, columnId, multiColumn } = this.props; const { id, local } = this.props.params; const pinned = !!columnId; - const { signedIn } = this.props.identity; return ( @@ -202,7 +180,7 @@ class HashtagTimeline extends PureComponent { } + prepend={pinned ? null : } alwaysPrepend trackScroll={!pinned} scrollKey={`hashtag_timeline-${columnId}`} diff --git a/app/javascript/mastodon/models/tags.ts b/app/javascript/mastodon/models/tags.ts new file mode 100644 index 0000000000..3a4b1fb23e --- /dev/null +++ b/app/javascript/mastodon/models/tags.ts @@ -0,0 +1,7 @@ +import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; + +export type Hashtag = ApiHashtagJSON; + +export const createHashtag = (serverJSON: ApiHashtagJSON): Hashtag => ({ + ...serverJSON, +}); diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 3f3d09b7ff..6da6abd81b 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -36,7 +36,6 @@ import settings from './settings'; import status_lists from './status_lists'; import statuses from './statuses'; import { suggestionsReducer } from './suggestions'; -import tags from './tags'; import timelines from './timelines'; import trends from './trends'; import user_lists from './user_lists'; @@ -76,7 +75,6 @@ const reducers = { markers: markersReducer, picture_in_picture: pictureInPictureReducer, history, - tags, followed_tags, notificationPolicy: notificationPolicyReducer, notificationRequests: notificationRequestsReducer, diff --git a/app/javascript/mastodon/reducers/tags.js b/app/javascript/mastodon/reducers/tags.js deleted file mode 100644 index 23a1ae82b5..0000000000 --- a/app/javascript/mastodon/reducers/tags.js +++ /dev/null @@ -1,26 +0,0 @@ -import { Map as ImmutableMap, fromJS } from 'immutable'; - -import { - HASHTAG_FETCH_SUCCESS, - HASHTAG_FOLLOW_REQUEST, - HASHTAG_FOLLOW_FAIL, - HASHTAG_UNFOLLOW_REQUEST, - HASHTAG_UNFOLLOW_FAIL, -} from 'mastodon/actions/tags'; - -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; - } -}