From 93b09d8206a2ea3ff4243a9a068249c0ed0505b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 19 Jan 2023 15:06:17 +0100 Subject: [PATCH 1/9] Add ability to follow hashtags in web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/tags.ts | 120 ++++++++++++++++++ app/soapbox/components/ui/card/card.tsx | 18 ++- app/soapbox/components/ui/column/column.tsx | 23 +++- .../features/hashtag-timeline/index.tsx | 28 +++- app/soapbox/normalizers/tag.ts | 1 + app/soapbox/reducers/index.ts | 2 + app/soapbox/reducers/tags.ts | 30 +++++ app/soapbox/utils/features.ts | 7 + 8 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 app/soapbox/actions/tags.ts create mode 100644 app/soapbox/reducers/tags.ts 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 From d891024cb54ab02f6410e4f2766f2e36ca3f887c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 20 Jan 2023 14:20:50 +0100 Subject: [PATCH 2/9] Update en.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/locales/en.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 05613e17f..899020207 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -695,6 +695,8 @@ "hashtag.column_header.tag_mode.all": "and {additional}", "hashtag.column_header.tag_mode.any": "or {additional}", "hashtag.column_header.tag_mode.none": "without {additional}", + "hashtag.follow": "Follow hashtag", + "hashtag.unfollow": "Unfollow hashtag", "header.home.label": "Home", "header.login.forgot_password": "Forgot password?", "header.login.label": "Log in", From 0ec5ec712977ef298e0169a4c82652f8a47be812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 20 Jan 2023 22:32:51 +0100 Subject: [PATCH 3/9] Update CHANGELOG.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cdc24413..8eac50477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Admin: redirect the homepage to any URL. - Compatibility: added compatibility with Friendica. +- Hashtags: let users follow hashtags (Mastodon). ### Changed From d4bcdf428f65c5f4fcacc20c9d76e9ec6e1f174d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 4 Feb 2023 11:44:12 +0100 Subject: [PATCH 4/9] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/utils/features.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 2f24c07f4..0068f17dd 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -463,6 +463,12 @@ const getInstanceFeatures = (instance: Instance) => { v.software === PLEROMA, ]), + /** + * Ability to list followed hashtags. + * @see GET /api/v1/followed_tags + */ + followedHashtagsList: v.software === MASTODON && gte(v.compatVersion, '4.1.0'), + /** * Whether client settings can be retrieved from the API. * @see GET /api/pleroma/frontend_configurations From c61368821a4b4d77916142256d6c8381a5479013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 2 May 2023 23:33:53 +0200 Subject: [PATCH 5/9] Use ListItem for 'Follow hashtag' setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/ui/column/column.tsx | 6 ++++-- .../features/hashtag-timeline/index.tsx | 21 ++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index 8813b107b..8d7c2da39 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -51,6 +51,8 @@ export interface IColumn { withHeader?: boolean /** Extra class name for top
element. */ className?: string + /** Extra class name for the element. */ + bodyClassName?: string /** Ref forwarded to column. */ ref?: React.Ref /** Children to display in the column. */ @@ -63,7 +65,7 @@ export interface IColumn { /** 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, action, size } = props; + const { backHref, children, label, transparent = false, withHeader = true, className, bodyClassName, action, size } = props; const soapboxConfig = useSoapboxConfig(); const [isScrolled, setIsScrolled] = useState(false); @@ -109,7 +111,7 @@ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedR /> )} - + {children} diff --git a/app/soapbox/features/hashtag-timeline/index.tsx b/app/soapbox/features/hashtag-timeline/index.tsx index 960b699df..e448bef8a 100644 --- a/app/soapbox/features/hashtag-timeline/index.tsx +++ b/app/soapbox/features/hashtag-timeline/index.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useRef } from 'react'; -import { useIntl, defineMessages } from 'react-intl'; +import { useIntl, defineMessages, FormattedMessage } 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 List, { ListItem } from 'soapbox/components/list'; +import { Column, Toggle } from 'soapbox/components/ui'; import Timeline from 'soapbox/features/ui/components/timeline'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; @@ -19,8 +20,6 @@ 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.' }, }); @@ -119,7 +118,19 @@ export const HashtagTimeline: React.FC = ({ params }) => { }, [id]); return ( - + + {features.followHashtags && ( + + } + > + + + + )} Date: Tue, 2 May 2023 23:34:46 +0200 Subject: [PATCH 6/9] Follow hashtags: Support Akkoma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/utils/features.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 04e5cc6e8..9fd16017d 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -491,7 +491,10 @@ const getInstanceFeatures = (instance: Instance) => { * @see POST /api/v1/tags/:name/follow * @see POST /api/v1/tags/:name/unfollow */ - followHashtags: v.software === MASTODON && gte(v.compatVersion, '4.0.0'), + followHashtags: any([ + v.software === MASTODON && gte(v.compatVersion, '4.0.0'), + v.software === PLEROMA && v.build === AKKOMA, + ]), /** * Ability to lock accounts and manually approve followers. From 610864d5a9b5fbbb858ce7f307c74ab594204adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 3 May 2023 00:21:53 +0200 Subject: [PATCH 7/9] Add followed tags list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/followed_tags/index.tsx | 52 ++++++++++++++++++++ app/soapbox/reducers/followed_tags.ts | 47 ++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 app/soapbox/features/followed_tags/index.tsx create mode 100644 app/soapbox/reducers/followed_tags.ts diff --git a/app/soapbox/features/followed_tags/index.tsx b/app/soapbox/features/followed_tags/index.tsx new file mode 100644 index 000000000..6745f5fc0 --- /dev/null +++ b/app/soapbox/features/followed_tags/index.tsx @@ -0,0 +1,52 @@ +import debounce from 'lodash/debounce'; +import React, { useEffect } from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { fetchFollowedHashtags, expandFollowedHashtags } from 'soapbox/actions/tags'; +import Hashtag from 'soapbox/components/hashtag'; +import ScrollableList from 'soapbox/components/scrollable-list'; +import { Column } from 'soapbox/components/ui'; +import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + heading: { id: 'column.followed_tags', defaultMessage: 'Followed hashtags' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandFollowedHashtags()); +}, 300, { leading: true }); + +const FollowedTags = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(fetchFollowedHashtags()); + }, []); + + const tags = useAppSelector((state => state.followed_tags.items)); + const isLoading = useAppSelector((state => state.followed_tags.isLoading)); + const hasMore = useAppSelector((state => !!state.followed_tags.next)); + + const emptyMessage = ; + + return ( + + handleLoadMore(dispatch)} + placeholderComponent={PlaceholderHashtag} + placeholderCount={5} + itemClassName='pb-3' + > + {tags.map(tag => )} + + + ); +}; + +export default FollowedTags; diff --git a/app/soapbox/reducers/followed_tags.ts b/app/soapbox/reducers/followed_tags.ts new file mode 100644 index 000000000..4f30a3f3a --- /dev/null +++ b/app/soapbox/reducers/followed_tags.ts @@ -0,0 +1,47 @@ +import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; + +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 'soapbox/actions/tags'; +import { normalizeTag } from 'soapbox/normalizers'; + +import type { AnyAction } from 'redux'; +import type { APIEntity, Tag } from 'soapbox/types/entities'; + +const ReducerRecord = ImmutableRecord({ + items: ImmutableList(), + isLoading: false, + next: null, +}); + +export default function followed_tags(state = ReducerRecord(), action: AnyAction) { + 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', ImmutableList(action.followed_tags.map((item: APIEntity) => normalizeTag(item)))); + 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', list => list.concat(action.followed_tags.map((item: APIEntity) => normalizeTag(item)))); + map.set('isLoading', false); + map.set('next', action.next); + }); + case FOLLOWED_HASHTAGS_EXPAND_FAIL: + return state.set('isLoading', false); + default: + return state; + } +} From 586f536329e31e9a3bd8429f952bfa64184d4668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 3 May 2023 00:26:29 +0200 Subject: [PATCH 8/9] Update changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- CHANGELOG.md | 4 +--- app/soapbox/locales/en.json | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47e5b2de2..179bee42b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Admin: redirect the homepage to any URL. -- Compatibility: added compatibility with Friendica. -- Hashtags: let users follow hashtags (Mastodon). +- Hashtags: let users follow hashtags (Mastodon, Akkoma). - Posts: Support posts filtering on recent Mastodon versions - Reactions: Support custom emoji reactions - Compatbility: Support Mastodon v2 timeline filters. diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 7f2cfadd5..0576f8576 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -673,6 +673,7 @@ "empty_column.filters": "You haven't created any muted words yet.", "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.", "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", + "empty_column.followed_tags": "You haven't followed any hashtag yet.", "empty_column.group": "There are no posts in this group yet.", "empty_column.group_blocks": "The group hasn't banned any users yet.", "empty_column.group_membership_requests": "There are no pending membership requests for this group.", From 41e969616dad0cf22e2214fd289ef1c279706fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 11 May 2023 20:10:22 +0200 Subject: [PATCH 9/9] Forgot to commit some files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/tags.ts | 83 ++++++++++++++++++++++++++++++++++- app/soapbox/locales/en.json | 1 - app/soapbox/reducers/index.ts | 2 + 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/app/soapbox/actions/tags.ts b/app/soapbox/actions/tags.ts index 2c394ba46..75d8e00fa 100644 --- a/app/soapbox/actions/tags.ts +++ b/app/soapbox/actions/tags.ts @@ -1,4 +1,4 @@ -import api from '../api'; +import api, { getLinks } from '../api'; import type { AxiosError } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; @@ -16,6 +16,14 @@ const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST'; const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS'; const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL'; +const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST'; +const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS'; +const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL'; + +const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST'; +const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS'; +const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL'; + const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(fetchHashtagRequest()); @@ -95,6 +103,65 @@ const unfollowHashtagFail = (name: string, error: AxiosError) => ({ error, }); +const fetchFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => { + 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)); + }); +}; + +const fetchFollowedHashtagsRequest = () => ({ + type: FOLLOWED_HASHTAGS_FETCH_REQUEST, +}); + +const fetchFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({ + type: FOLLOWED_HASHTAGS_FETCH_SUCCESS, + followed_tags, + next, +}); + +const fetchFollowedHashtagsFail = (error: AxiosError) => ({ + type: FOLLOWED_HASHTAGS_FETCH_FAIL, + error, +}); + +const expandFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().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)); + }); +}; + +const expandFollowedHashtagsRequest = () => ({ + type: FOLLOWED_HASHTAGS_EXPAND_REQUEST, +}); + +const expandFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({ + type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS, + followed_tags, + next, +}); + +const expandFollowedHashtagsFail = (error: AxiosError) => ({ + type: FOLLOWED_HASHTAGS_EXPAND_FAIL, + error, +}); + + export { HASHTAG_FETCH_REQUEST, HASHTAG_FETCH_SUCCESS, @@ -105,6 +172,12 @@ export { HASHTAG_UNFOLLOW_REQUEST, HASHTAG_UNFOLLOW_SUCCESS, HASHTAG_UNFOLLOW_FAIL, + FOLLOWED_HASHTAGS_FETCH_REQUEST, + FOLLOWED_HASHTAGS_FETCH_SUCCESS, + FOLLOWED_HASHTAGS_FETCH_FAIL, + FOLLOWED_HASHTAGS_EXPAND_REQUEST, + FOLLOWED_HASHTAGS_EXPAND_SUCCESS, + FOLLOWED_HASHTAGS_EXPAND_FAIL, fetchHashtag, fetchHashtagRequest, fetchHashtagSuccess, @@ -117,4 +190,12 @@ export { unfollowHashtagRequest, unfollowHashtagSuccess, unfollowHashtagFail, + fetchFollowedHashtags, + fetchFollowedHashtagsRequest, + fetchFollowedHashtagsSuccess, + fetchFollowedHashtagsFail, + expandFollowedHashtags, + expandFollowedHashtagsRequest, + expandFollowedHashtagsSuccess, + expandFollowedHashtagsFail, }; diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 11badebe2..919b33901 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -671,7 +671,6 @@ "empty_column.filters": "You haven't created any muted words yet.", "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.", "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", - "empty_column.followed_tags": "You haven't followed any hashtag yet.", "empty_column.group": "There are no posts in this group yet.", "empty_column.group_blocks": "The group hasn't banned any users yet.", "empty_column.group_membership_requests": "There are no pending membership requests for this group.", diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index b02ae8117..bde340b60 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -28,6 +28,7 @@ import custom_emojis from './custom-emojis'; import domain_lists from './domain-lists'; import dropdown_menu from './dropdown-menu'; import filters from './filters'; +import followed_tags from './followed_tags'; import group_memberships from './group-memberships'; import group_relationships from './group-relationships'; import groups from './groups'; @@ -93,6 +94,7 @@ const reducers = { dropdown_menu, entities, filters, + followed_tags, group_memberships, group_relationships, groups,