diff --git a/CHANGELOG.md b/CHANGELOG.md
index f83896efc..56d312320 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- 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/actions/tags.ts b/app/soapbox/actions/tags.ts
new file mode 100644
index 000000000..75d8e00fa
--- /dev/null
+++ b/app/soapbox/actions/tags.ts
@@ -0,0 +1,201 @@
+import api, { getLinks } 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 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());
+
+ 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,
+});
+
+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,
+ HASHTAG_FETCH_FAIL,
+ HASHTAG_FOLLOW_REQUEST,
+ HASHTAG_FOLLOW_SUCCESS,
+ HASHTAG_FOLLOW_FAIL,
+ 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,
+ fetchHashtagFail,
+ followHashtag,
+ followHashtagRequest,
+ followHashtagSuccess,
+ followHashtagFail,
+ unfollowHashtag,
+ unfollowHashtagRequest,
+ unfollowHashtagSuccess,
+ unfollowHashtagFail,
+ fetchFollowedHashtags,
+ fetchFollowedHashtagsRequest,
+ fetchFollowedHashtagsSuccess,
+ fetchFollowedHashtagsFail,
+ expandFollowedHashtags,
+ expandFollowedHashtagsRequest,
+ expandFollowedHashtagsSuccess,
+ expandFollowedHashtagsFail,
+};
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/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/features/hashtag-timeline/index.tsx b/app/soapbox/features/hashtag-timeline/index.tsx
index 2133e3e3a..e448bef8a 100644
--- a/app/soapbox/features/hashtag-timeline/index.tsx
+++ b/app/soapbox/features/hashtag-timeline/index.tsx
@@ -1,11 +1,13 @@
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 } from 'soapbox/hooks';
+import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import type { Tag as TagEntity } from 'soapbox/types/entities';
@@ -32,9 +34,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 +92,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 +118,19 @@ export const HashtagTimeline: React.FC = ({ params }) => {
}, [id]);
return (
-
+
+ {features.followHashtags && (
+
+ }
+ >
+
+
+
+ )}
= ({ params }) => {
);
};
-export default HashtagTimeline;
\ No newline at end of file
+export default HashtagTimeline;
diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json
index 6138dbdd5..919b33901 100644
--- a/app/soapbox/locales/en.json
+++ b/app/soapbox/locales/en.json
@@ -858,6 +858,7 @@
"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",
"header.home.label": "Home",
"header.login.email.placeholder": "E-mail address",
"header.login.forgot_password": "Forgot password?",
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/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;
+ }
+}
diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts
index 82b93e791..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';
@@ -61,6 +62,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';
@@ -92,6 +94,7 @@ const reducers = {
dropdown_menu,
entities,
filters,
+ followed_tags,
group_memberships,
group_relationships,
groups,
@@ -125,6 +128,7 @@ const reducers = {
status_lists,
statuses,
suggestions,
+ tags,
timelines,
trending_statuses,
trends,
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 5ade736e4..be3650fc1 100644
--- a/app/soapbox/utils/features.ts
+++ b/app/soapbox/utils/features.ts
@@ -493,6 +493,16 @@ 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: any([
+ v.software === MASTODON && gte(v.compatVersion, '4.0.0'),
+ v.software === PLEROMA && v.build === AKKOMA,
+ ]),
+
/**
* Ability to lock accounts and manually approve followers.
* @see PATCH /api/v1/accounts/update_credentials
@@ -502,6 +512,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