diff --git a/app/soapbox/actions/tags.ts b/app/soapbox/actions/tags.ts new file mode 100644 index 000000000..2c394ba46 --- /dev/null +++ b/app/soapbox/actions/tags.ts @@ -0,0 +1,120 @@ +import api from '../api'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST'; +const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS'; +const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL'; + +const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST'; +const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS'; +const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL'; + +const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST'; +const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS'; +const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL'; + +const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchHashtagRequest()); + + api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => { + dispatch(fetchHashtagSuccess(name, data)); + }).catch(err => { + dispatch(fetchHashtagFail(err)); + }); +}; + +const fetchHashtagRequest = () => ({ + type: HASHTAG_FETCH_REQUEST, +}); + +const fetchHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_FETCH_SUCCESS, + name, + tag, +}); + +const fetchHashtagFail = (error: AxiosError) => ({ + type: HASHTAG_FETCH_FAIL, + error, +}); + +const followHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(followHashtagRequest(name)); + + api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => { + dispatch(followHashtagSuccess(name, data)); + }).catch(err => { + dispatch(followHashtagFail(name, err)); + }); +}; + +const followHashtagRequest = (name: string) => ({ + type: HASHTAG_FOLLOW_REQUEST, + name, +}); + +const followHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_FOLLOW_SUCCESS, + name, + tag, +}); + +const followHashtagFail = (name: string, error: AxiosError) => ({ + type: HASHTAG_FOLLOW_FAIL, + name, + error, +}); + +const unfollowHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(unfollowHashtagRequest(name)); + + api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => { + dispatch(unfollowHashtagSuccess(name, data)); + }).catch(err => { + dispatch(unfollowHashtagFail(name, err)); + }); +}; + +const unfollowHashtagRequest = (name: string) => ({ + type: HASHTAG_UNFOLLOW_REQUEST, + name, +}); + +const unfollowHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_UNFOLLOW_SUCCESS, + name, + tag, +}); + +const unfollowHashtagFail = (name: string, error: AxiosError) => ({ + type: HASHTAG_UNFOLLOW_FAIL, + name, + error, +}); + +export { + HASHTAG_FETCH_REQUEST, + HASHTAG_FETCH_SUCCESS, + HASHTAG_FETCH_FAIL, + HASHTAG_FOLLOW_REQUEST, + HASHTAG_FOLLOW_SUCCESS, + HASHTAG_FOLLOW_FAIL, + HASHTAG_UNFOLLOW_REQUEST, + HASHTAG_UNFOLLOW_SUCCESS, + HASHTAG_UNFOLLOW_FAIL, + fetchHashtag, + fetchHashtagRequest, + fetchHashtagSuccess, + fetchHashtagFail, + followHashtag, + followHashtagRequest, + followHashtagSuccess, + followHashtagFail, + unfollowHashtag, + unfollowHashtagRequest, + unfollowHashtagSuccess, + unfollowHashtagFail, +}; diff --git a/app/soapbox/components/ui/card/card.tsx b/app/soapbox/components/ui/card/card.tsx index 6fc85a39a..2cc23b6b4 100644 --- a/app/soapbox/components/ui/card/card.tsx +++ b/app/soapbox/components/ui/card/card.tsx @@ -45,6 +45,12 @@ interface ICardHeader { backHref?: string, onBackClick?: (event: React.MouseEvent) => void className?: string + /** Callback when the card action is clicked. */ + onActionClick?: () => void, + /** URL to the svg icon for the card action. */ + actionIcon?: string, + /** Text for the action. */ + actionTitle?: string, children?: React.ReactNode } @@ -52,7 +58,7 @@ interface ICardHeader { * Card header container with back button. * Typically holds a CardTitle. */ -const CardHeader: React.FC = ({ className, children, backHref, onBackClick }): JSX.Element => { +const CardHeader: React.FC = ({ className, children, backHref, onBackClick, onActionClick, actionIcon, actionTitle }): JSX.Element => { const intl = useIntl(); const renderBackButton = () => { @@ -64,7 +70,7 @@ const CardHeader: React.FC = ({ className, children, backHref, onBa const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick }; return ( - + {intl.formatMessage(messages.back)} @@ -76,6 +82,12 @@ const CardHeader: React.FC = ({ className, children, backHref, onBa {renderBackButton()} {children} + + {onActionClick && actionIcon && ( + + )} ); }; @@ -86,7 +98,7 @@ interface ICardTitle { /** A card's title. */ const CardTitle: React.FC = ({ title }): JSX.Element => ( - {title} + {title} ); interface ICardBody { diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index 5ccf3ac42..7099b792d 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -7,10 +7,10 @@ import { useSoapboxConfig } from 'soapbox/hooks'; import { Card, CardBody, CardHeader, CardTitle } from '../card/card'; -type IColumnHeader = Pick; +type IColumnHeader = Pick; /** Contains the column title with optional back button. */ -const ColumnHeader: React.FC = ({ label, backHref, className }) => { +const ColumnHeader: React.FC = ({ label, backHref, className, onActionClick, actionIcon, actionTitle }) => { const history = useHistory(); const handleBackClick = () => { @@ -27,7 +27,13 @@ const ColumnHeader: React.FC = ({ label, backHref, className }) = }; return ( - + ); @@ -46,13 +52,19 @@ export interface IColumn { className?: string, /** Ref forwarded to column. */ ref?: React.Ref + /** Callback when the column action is clicked. */ + onActionClick?: () => void, + /** URL to the svg icon for the column action. */ + actionIcon?: string, + /** Text for the action. */ + actionTitle?: string, /** Children to display in the column. */ children?: React.ReactNode } /** A backdrop for the main section of the UI. */ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedRef): JSX.Element => { - const { backHref, children, label, transparent = false, withHeader = true, className } = props; + const { backHref, children, label, transparent = false, withHeader = true, onActionClick, actionIcon, actionTitle, className } = props; const soapboxConfig = useSoapboxConfig(); return ( @@ -75,6 +87,9 @@ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedR label={label} backHref={backHref} className={classNames({ 'px-4 pt-4 sm:p-0': transparent })} + onActionClick={onActionClick} + actionIcon={actionIcon} + actionTitle={actionTitle} /> )} diff --git a/app/soapbox/features/hashtag-timeline/index.tsx b/app/soapbox/features/hashtag-timeline/index.tsx index 133a96a5f..2587ff374 100644 --- a/app/soapbox/features/hashtag-timeline/index.tsx +++ b/app/soapbox/features/hashtag-timeline/index.tsx @@ -2,10 +2,11 @@ import React, { useEffect, useRef } from 'react'; import { useIntl, defineMessages } from 'react-intl'; import { connectHashtagStream } from 'soapbox/actions/streaming'; +import { fetchHashtag, followHashtag, unfollowHashtag } from 'soapbox/actions/tags'; import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines'; import { Column } from 'soapbox/components/ui'; import Timeline from 'soapbox/features/ui/components/timeline'; -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import type { Tag as TagEntity } from 'soapbox/types/entities'; @@ -18,6 +19,8 @@ const messages = defineMessages({ any: { id: 'hashtag.column_header.tag_mode.any', defaultMessage: 'or {additional}' }, all: { id: 'hashtag.column_header.tag_mode.all', defaultMessage: 'and {additional}' }, none: { id: 'hashtag.column_header.tag_mode.none', defaultMessage: 'without {additional}' }, + followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, + unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' }, empty: { id: 'empty_column.hashtag', defaultMessage: 'There is nothing in this hashtag yet.' }, }); @@ -32,9 +35,11 @@ export const HashtagTimeline: React.FC = ({ params }) => { const intl = useIntl(); const id = params?.id || ''; const tags = params?.tags || { any: [], all: [], none: [] }; - + + const features = useFeatures(); const dispatch = useAppDispatch(); const disconnects = useRef<(() => void)[]>([]); + const tag = useAppSelector((state) => state.tags.get(id)); // Mastodon supports displaying results from multiple hashtags. // https://github.com/mastodon/mastodon/issues/6359 @@ -88,9 +93,18 @@ export const HashtagTimeline: React.FC = ({ params }) => { dispatch(expandHashtagTimeline(id, { maxId, tags })); }; + const handleFollow = () => { + if (tag?.following) { + dispatch(unfollowHashtag(id)); + } else { + dispatch(followHashtag(id)); + } + }; + useEffect(() => { subscribe(); dispatch(expandHashtagTimeline(id, { tags })); + dispatch(fetchHashtag(id)); return () => { unsubscribe(); @@ -105,7 +119,13 @@ export const HashtagTimeline: React.FC = ({ params }) => { }, [id]); return ( - + = ({ params }) => { ); }; -export default HashtagTimeline; \ No newline at end of file +export default HashtagTimeline; diff --git a/app/soapbox/normalizers/tag.ts b/app/soapbox/normalizers/tag.ts index 6d0ebae14..fde58241f 100644 --- a/app/soapbox/normalizers/tag.ts +++ b/app/soapbox/normalizers/tag.ts @@ -19,6 +19,7 @@ export const TagRecord = ImmutableRecord({ name: '', url: '', history: null as ImmutableList | null, + following: false, }); const normalizeHistoryList = (tag: ImmutableMap) => { diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index 5b5cca999..1d1d71802 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -56,6 +56,7 @@ import status_hover_card from './status-hover-card'; import status_lists from './status-lists'; import statuses from './statuses'; import suggestions from './suggestions'; +import tags from './tags'; import timelines from './timelines'; import trending_statuses from './trending-statuses'; import trends from './trends'; @@ -120,6 +121,7 @@ const reducers = { announcements, compose_event, admin_user_index, + tags, }; // Build a default state from all reducers: it has the key and `undefined` diff --git a/app/soapbox/reducers/tags.ts b/app/soapbox/reducers/tags.ts new file mode 100644 index 000000000..81488bb1e --- /dev/null +++ b/app/soapbox/reducers/tags.ts @@ -0,0 +1,30 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { + HASHTAG_FETCH_SUCCESS, + HASHTAG_FOLLOW_REQUEST, + HASHTAG_FOLLOW_FAIL, + HASHTAG_UNFOLLOW_REQUEST, + HASHTAG_UNFOLLOW_FAIL, +} from 'soapbox/actions/tags'; +import { normalizeTag } from 'soapbox/normalizers'; + +import type { AnyAction } from 'redux'; +import type { Tag } from 'soapbox/types/entities'; + +const initialState = ImmutableMap(); + +export default function tags(state = initialState, action: AnyAction) { + switch (action.type) { + case HASHTAG_FETCH_SUCCESS: + return state.set(action.name, normalizeTag(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; + } +} diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 39950a534..2f24c07f4 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -447,6 +447,13 @@ const getInstanceFeatures = (instance: Instance) => { */ focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'), + /** + * Ability to follow hashtags. + * @see POST /api/v1/tags/:name/follow + * @see POST /api/v1/tags/:name/unfollow + */ + followHashtags: v.software === MASTODON && gte(v.compatVersion, '4.0.0'), + /** * Ability to lock accounts and manually approve followers. * @see PATCH /api/v1/accounts/update_credentials