diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 000000000..7a73a41bf
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,2 @@
+{
+}
\ No newline at end of file
diff --git a/app/soapbox/actions/tags.ts b/app/soapbox/actions/tags.ts
new file mode 100644
index 000000000..d61b2d9eb
--- /dev/null
+++ b/app/soapbox/actions/tags.ts
@@ -0,0 +1,119 @@
+import { isLoggedIn } from 'soapbox/utils/auth';
+import { getFeatures } from 'soapbox/utils/features';
+
+import api, { getNextLink } from '../api';
+
+import type { AppDispatch, RootState } from 'soapbox/store';
+
+const TAG_FETCH_REQUEST = 'TAG_FETCH_REQUEST';
+const TAG_FETCH_SUCCESS = 'TAG_FETCH_SUCCESS';
+const TAG_FETCH_FAIL = 'TAG_FETCH_FAIL';
+
+const TAG_FOLLOW_REQUEST = 'TAG_FOLLOW_REQUEST';
+const TAG_FOLLOW_SUCCESS = 'TAG_FOLLOW_SUCCESS';
+const TAG_FOLLOW_FAIL = 'TAG_FOLLOW_FAIL';
+
+const TAG_UNFOLLOW_REQUEST = 'TAG_UNFOLLOW_REQUEST';
+const TAG_UNFOLLOW_SUCCESS = 'TAG_UNFOLLOW_SUCCESS';
+const TAG_UNFOLLOW_FAIL = 'TAG_UNFOLLOW_FAIL';
+
+const fetchTags = () => async(dispatch: AppDispatch, getState: () => RootState) => {
+ if (!isLoggedIn(getState)) return;
+
+ const state = getState();
+ const instance = state.instance;
+ const features = getFeatures(instance);
+
+ if (!features.followTags) return;
+
+ dispatch({ type: TAG_FETCH_REQUEST, skipLoading: true });
+
+ try {
+ let next = null;
+ let tags = [];
+ do {
+ const response = await api(getState).get(next || '/api/v1/followed_tags');
+ tags = [...tags, ...response.data];
+ next = getNextLink(response);
+ } while (next);
+ dispatch({
+ type: TAG_FETCH_SUCCESS,
+ tags,
+ skipLoading: true,
+ });
+ } catch (err) {
+ dispatch({
+ type: TAG_FETCH_FAIL,
+ err,
+ skipLoading: true,
+ skipAlert: true,
+ });
+ }
+};
+
+const followTag = (tagId: string) => async(dispatch: AppDispatch, getState: () => RootState) => {
+ if (!isLoggedIn(getState)) return;
+
+ const state = getState();
+ const features = getFeatures(state.instance);
+
+ if (!features.followTags) return;
+
+ dispatch({ type: TAG_FOLLOW_REQUEST });
+
+ try {
+ const { data } = await api(getState).post(`/api/v1/tags/${tagId}/follow`);
+ dispatch({
+ type: TAG_FOLLOW_SUCCESS,
+ tag: data,
+ });
+
+ } catch (err) {
+ dispatch({
+ type: TAG_FOLLOW_FAIL,
+ err,
+ });
+ }
+};
+
+const unfollowTag = (tagId: string) => async(dispatch: AppDispatch, getState: () => RootState) => {
+ if (!isLoggedIn(getState)) return;
+
+ const state = getState();
+ const features = getFeatures(state.instance);
+
+ if (!features.followTags) return;
+
+ dispatch({ type: TAG_UNFOLLOW_REQUEST });
+
+ try {
+ const { data } = await api(getState).post(`/api/v1/tags/${tagId}/unfollow`);
+ dispatch({
+ type: TAG_UNFOLLOW_SUCCESS,
+ tag: data,
+ });
+
+ } catch (err) {
+ dispatch({
+ type: TAG_UNFOLLOW_FAIL,
+ err,
+ });
+ }
+};
+
+export {
+ fetchTags,
+ TAG_FETCH_FAIL,
+ TAG_FETCH_REQUEST,
+ TAG_FETCH_SUCCESS,
+
+ followTag,
+ TAG_FOLLOW_FAIL,
+ TAG_FOLLOW_REQUEST,
+ TAG_FOLLOW_SUCCESS,
+
+ unfollowTag,
+ TAG_UNFOLLOW_FAIL,
+ TAG_UNFOLLOW_REQUEST,
+ TAG_UNFOLLOW_SUCCESS,
+};
\ No newline at end of file
diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx
index a9d23f891..cc83d1dc0 100644
--- a/app/soapbox/components/sidebar-navigation.tsx
+++ b/app/soapbox/components/sidebar-navigation.tsx
@@ -22,6 +22,7 @@ const messages = defineMessages({
settings: { id: 'tabs_bar.settings', defaultMessage: 'Settings' },
direct: { id: 'column.direct', defaultMessage: 'Direct messages' },
directory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' },
+ tags: { id: 'navigation_bar.tags', defaultMessage: 'Hashtags' },
});
/** Desktop sidebar with links to different views in the app. */
@@ -79,6 +80,14 @@ const SidebarNavigation = () => {
});
}
+ if (features.followTags) {
+ menu.push({
+ to: '/followed_hashtags',
+ text: intl.formatMessage(messages.tags),
+ icon: require('@tabler/icons/hash.svg'),
+ });
+ }
+
if (features.profileDirectory) {
menu.push({
to: '/directory',
diff --git a/app/soapbox/components/sidebar_menu.tsx b/app/soapbox/components/sidebar_menu.tsx
index b464d111e..f0b6f64e5 100644
--- a/app/soapbox/components/sidebar_menu.tsx
+++ b/app/soapbox/components/sidebar_menu.tsx
@@ -38,6 +38,7 @@ const messages = defineMessages({
direct: { id: 'column.direct', defaultMessage: 'Direct messages' },
directory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' },
dashboard: { id: 'tabs_bar.dashboard', defaultMessage: 'Dashboard' },
+ tags: { id: 'navigation_bar.tags', defaultMessage: 'Hashtags' },
});
interface ISidebarLink {
@@ -243,6 +244,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
/>
)}
+ {features.followTags && (
+
+ )}
+
{features.profileDirectory && (
= ({ title }): JSX.Element => (
- {title}
+ {title}
);
/** A card's body. */
diff --git a/app/soapbox/components/ui/icon-button/icon-button.tsx b/app/soapbox/components/ui/icon-button/icon-button.tsx
index 6f0eb869e..9402c1280 100644
--- a/app/soapbox/components/ui/icon-button/icon-button.tsx
+++ b/app/soapbox/components/ui/icon-button/icon-button.tsx
@@ -10,7 +10,7 @@ interface IIconButton extends React.ButtonHTMLAttributes {
/** URL to the svg icon. */
src: string,
/** Text to display next ot the button. */
- text?: string,
+ text?: React.ReactNode,
/** Don't render a background behind the icon. */
transparent?: boolean,
/** Predefined styles to display for the button. */
diff --git a/app/soapbox/features/followed_tags/index.tsx b/app/soapbox/features/followed_tags/index.tsx
new file mode 100644
index 000000000..e6ca0af2d
--- /dev/null
+++ b/app/soapbox/features/followed_tags/index.tsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+
+import { fetchTags, followTag, unfollowTag } from 'soapbox/actions/tags';
+import Icon from 'soapbox/components/icon';
+import ScrollableList from 'soapbox/components/scrollable_list';
+import { Button, Column, IconButton, Spinner, Text } from 'soapbox/components/ui';
+import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
+
+const messages = defineMessages({
+ heading: { id: 'column.tags', defaultMessage: 'Followed hashtags' },
+});
+
+interface IFollowButton {
+ id: string,
+ }
+
+const FollowButton: React.FC = ({ id }) => {
+ const { isFollow } = useAppSelector(state => ({ isFollow: state.tags.list.find((t) => t.name === id) }));
+ const dispatch = useAppDispatch();
+
+ const onClick = React.useCallback(() => {
+ const action = isFollow ? unfollowTag : followTag;
+ dispatch(action(id));
+ }, [isFollow, id]);
+
+ const text = React.useMemo(() => {
+ return isFollow ? (
+
+
+ ) : (
+
+ );
+ }, [isFollow]);
+
+
+ return (
+
+ );
+};
+
+const FollowedHashtags = () => {
+ const intl = useIntl();
+ const [tags, setTags] = React.useState(null);
+ const dispatch = useAppDispatch();
+
+ const { tags: serverTags, loading } = useAppSelector((state) => ({ tags: state.tags.list, loading: state.tags.loading }));
+
+ // we want to keep our own list to allow user to refollow instantly if unfollow was a mistake
+ React.useEffect(() => {
+ if (loading || tags) return;
+ setTags(serverTags);
+ }, [serverTags, tags, loading]);
+
+ React.useEffect(() => {
+ dispatch(fetchTags());
+ }, [dispatch]);
+
+
+ return (
+
+ {
+ !tags ? (
+
+ ) : (
+ }
+ >
+ {
+ tags?.map((tag) => (
+
+
+
+
+ { tag.name }
+
+
+
+
+ ))
+ }
+
+ )
+ }
+
+
+ );
+};
+
+export default FollowedHashtags;
diff --git a/app/soapbox/features/hashtag_timeline/index.js b/app/soapbox/features/hashtag_timeline/index.js
deleted file mode 100644
index d1611a8c8..000000000
--- a/app/soapbox/features/hashtag_timeline/index.js
+++ /dev/null
@@ -1,130 +0,0 @@
-import isEqual from 'lodash/isEqual';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-
-import { connectHashtagStream } from '../../actions/streaming';
-import { expandHashtagTimeline, clearTimeline } from '../../actions/timelines';
-import ColumnHeader from '../../components/column_header';
-import { Column } from '../../components/ui';
-import Timeline from '../ui/components/timeline';
-
-const mapStateToProps = (state, props) => ({
- hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
-});
-
-export default @connect(mapStateToProps)
-class HashtagTimeline extends React.PureComponent {
-
- disconnects = [];
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- hasUnread: PropTypes.bool,
- };
-
- title = () => {
- const title = [`#${this.props.params.id}`];
-
- // TODO: wtf is all this?
- // It exists in Mastodon's codebase, but undocumented
- if (this.additionalFor('any')) {
- title.push(' ', );
- }
-
- if (this.additionalFor('all')) {
- title.push(' ', );
- }
-
- if (this.additionalFor('none')) {
- title.push(' ', );
- }
-
- return title;
- }
-
- // TODO: wtf is this?
- // It exists in Mastodon's codebase, but undocumented
- additionalFor = (mode) => {
- const { tags } = this.props.params;
-
- if (tags && (tags[mode] || []).length > 0) {
- return tags[mode].map(tag => tag.value).join('/');
- } else {
- return '';
- }
- }
-
- _subscribe(dispatch, id, tags = {}) {
- const any = (tags.any || []).map(tag => tag.value);
- const all = (tags.all || []).map(tag => tag.value);
- const none = (tags.none || []).map(tag => tag.value);
-
- [id, ...any].map(tag => {
- this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => {
- const tags = status.tags.map(tag => tag.name);
-
- return all.filter(tag => tags.includes(tag)).length === all.length &&
- none.filter(tag => tags.includes(tag)).length === 0;
- })));
- });
- }
-
- _unsubscribe() {
- this.disconnects.map(disconnect => disconnect());
- this.disconnects = [];
- }
-
- componentDidMount() {
- const { dispatch } = this.props;
- const { id, tags } = this.props.params;
-
- this._subscribe(dispatch, id, tags);
- dispatch(expandHashtagTimeline(id, { tags }));
- }
-
- componentDidUpdate(prevProps) {
- const { dispatch } = this.props;
- const { id, tags } = this.props.params;
- const { id: prevId, tags: prevTags } = prevProps.params;
-
- if (id !== prevId || !isEqual(tags, prevTags)) {
- this._unsubscribe();
- this._subscribe(dispatch, id, tags);
- dispatch(clearTimeline(`hashtag:${id}`));
- dispatch(expandHashtagTimeline(id, { tags }));
- }
- }
-
- componentWillUnmount() {
- this._unsubscribe();
- }
-
- handleLoadMore = maxId => {
- const { id, tags } = this.props.params;
- this.props.dispatch(expandHashtagTimeline(id, { maxId, tags }));
- }
-
- render() {
- const { hasUnread } = this.props;
- const { id } = this.props.params;
-
- return (
-
-
-
-
- }
- divideType='space'
- />
-
- );
- }
-
-}
diff --git a/app/soapbox/features/hashtag_timeline/index.tsx b/app/soapbox/features/hashtag_timeline/index.tsx
new file mode 100644
index 000000000..80adebed4
--- /dev/null
+++ b/app/soapbox/features/hashtag_timeline/index.tsx
@@ -0,0 +1,156 @@
+import isEqual from 'lodash/isEqual';
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { useParams } from 'react-router-dom';
+
+import { unfollowTag, followTag } from 'soapbox/actions/tags';
+import Icon from 'soapbox/components/icon';
+import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
+
+import { connectHashtagStream } from '../../actions/streaming';
+import { expandHashtagTimeline, clearTimeline } from '../../actions/timelines';
+import ColumnHeader from '../../components/column_header';
+import { Button, Column, Spinner } from '../../components/ui';
+import Timeline from '../ui/components/timeline';
+
+interface IFollowButton {
+ id: string,
+}
+
+const FollowButton: React.FC = ({ id }) => {
+ const { isFollow, loading } = useAppSelector(state => ({ loading: state.tags.loading, isFollow: state.tags.list.find((t) => t.name === id) }));
+ const dispatch = useAppDispatch();
+
+ const onClick = React.useCallback(() => {
+ const action = isFollow ? unfollowTag : followTag;
+ dispatch(action(id));
+ }, [isFollow, id]);
+
+ const text = React.useMemo(() => {
+ if (loading) return ;
+ return isFollow ? (
+
+
+ ) : (
+
+ );
+ }, [loading, isFollow]);
+
+
+ return (
+
+ );
+};
+
+
+const HashtagTimeline: React.FC = () => {
+ const dispatch = useAppDispatch();
+ const disconnects = React.useRef([]);
+ const { id, tags } = useParams<{ id: string, tags: any }>();
+ const prevParams = React.useRef({ id, tags });
+
+ const hasUnread = useAppSelector((state) => (state.getIn(['timelines', `hashtag:${id}`, 'unread']) as number) > 0);
+
+ // TODO: wtf is this?
+ // It exists in Mastodon's codebase, but undocumented
+ const additionalFor = React.useCallback((mode) => {
+ if (tags && (tags[mode] || []).length > 0) {
+ return tags[mode].map(tag => tag.value).join('/');
+ } else {
+ return '';
+ }
+ }, [tags]);
+
+
+ const title = React.useMemo(() => {
+ const t: React.ReactNode[] = [`#${id}`];
+
+ // TODO: wtf is all this?
+ // It exists in Mastodon's codebase, but undocumented
+ if (additionalFor('any')) {
+ t.push(' ', );
+ }
+
+ if (additionalFor('all')) {
+ t.push(' ', );
+ }
+
+ if (additionalFor('none')) {
+ t.push(' ', );
+ }
+
+ return t;
+ }, [id, additionalFor]);
+
+
+ const subscribe = React.useCallback((dispatch, _id, tags = {}) => {
+ const any = (tags.any || []).map(tag => tag.value);
+ const all = (tags.all || []).map(tag => tag.value);
+ const none = (tags.none || []).map(tag => tag.value);
+
+ [_id, ...any].map(tag => {
+ disconnects.current.push(dispatch(connectHashtagStream(_id, tag, status => {
+ const tags = status.tags.map(tag => tag.name);
+
+ return all.filter(tag => tags.includes(tag)).length === all.length &&
+ none.filter(tag => tags.includes(tag)).length === 0;
+ })));
+ });
+ }, []);
+
+ const unsubscribe = React.useCallback(() => {
+ disconnects.current.map((d) => d());
+ disconnects.current = [];
+ }, []);
+
+ React.useEffect(() => {
+ const { id: prevId, tags: prevTags } = prevParams.current;
+ if (id !== prevId || !isEqual(tags, prevTags)) {
+ dispatch(clearTimeline(`hashtag:${id}`));
+ }
+
+ subscribe(dispatch, id, tags);
+ dispatch(expandHashtagTimeline(id, { tags }));
+
+ return () => {
+ unsubscribe();
+ };
+ }, [dispatch, tags, id, subscribe, unsubscribe]);
+
+
+ const handleLoadMore = React.useCallback((maxId) => {
+ dispatch(expandHashtagTimeline(id, { maxId, tags }));
+ }, [dispatch, id, tags]);
+
+
+ return (
+
+
+
+ { title }
+
+
+ }
+ />
+
+ }
+ divideType='space'
+ />
+
+ );
+};
+
+export default HashtagTimeline;
\ No newline at end of file
diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx
index 428c6aea7..a5c199f69 100644
--- a/app/soapbox/features/ui/index.tsx
+++ b/app/soapbox/features/ui/index.tsx
@@ -21,6 +21,7 @@ import { register as registerPushNotifications } from 'soapbox/actions/push_noti
import { fetchScheduledStatuses } from 'soapbox/actions/scheduled_statuses';
import { connectUserStream } from 'soapbox/actions/streaming';
import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions';
+import { fetchTags } from 'soapbox/actions/tags';
import { expandHomeTimeline } from 'soapbox/actions/timelines';
import Icon from 'soapbox/components/icon';
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
@@ -114,6 +115,7 @@ import {
LogoutPage,
AuthTokenList,
ProfileFields,
+ FollowedHashtags,
} from './util/async-components';
import { WrappedRoute } from './util/react_router_helpers';
@@ -259,6 +261,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
{features.chats && }
+
{features.federating && }
@@ -465,11 +468,13 @@ const UI: React.FC = ({ children }) => {
setTimeout(() => dispatch(fetchFilters()), 500);
+ setTimeout(() => dispatch(fetchTags()), 700);
+
if (account.locked) {
- setTimeout(() => dispatch(fetchFollowRequests()), 700);
+ setTimeout(() => dispatch(fetchFollowRequests()), 900);
}
- setTimeout(() => dispatch(fetchScheduledStatuses()), 900);
+ setTimeout(() => dispatch(fetchScheduledStatuses()), 1100);
};
useEffect(() => {
diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts
index 990d76070..2eef38b9e 100644
--- a/app/soapbox/features/ui/util/async-components.ts
+++ b/app/soapbox/features/ui/util/async-components.ts
@@ -525,3 +525,7 @@ export function FamiliarFollowersModal() {
export function AnnouncementsPanel() {
return import(/* webpackChunkName: "features/announcements" */'../../../components/announcements/announcements-panel');
}
+
+export function FollowedHashtags() {
+ return import('../../../features/followed_tags');
+}
diff --git a/app/soapbox/locales/fr.json b/app/soapbox/locales/fr.json
index f8038ef7e..eb442ac18 100644
--- a/app/soapbox/locales/fr.json
+++ b/app/soapbox/locales/fr.json
@@ -224,7 +224,7 @@
"column.direct": "Messages privés",
"column.directory": "Annuaire",
"column.domain_blocks": "Domaines cachés",
- "column.edit_profile": "Edit profile",
+ "column.edit_profile": "Éditer le profil",
"column.export_data": "Exporter vos données",
"column.familiar_followers": "People you know following {name}",
"column.favourited_statuses": "Liked posts",
@@ -436,7 +436,7 @@
"edit_profile.fields.stranger_notifications_label": "Bloquer les notifications d'inconnus",
"edit_profile.fields.website_label": "Website",
"edit_profile.fields.website_placeholder": "Display a Link",
- "edit_profile.header": "Edit Profile",
+ "edit_profile.header": "Éditer le profil",
"edit_profile.hints.accepts_email_list": "Opt-in to news and marketing updates.",
"edit_profile.hints.avatar": "PNG, GIF or JPG. Sera réduit à {size}",
"edit_profile.hints.bot": "Ce compte réalise essentiellement des actions automatiques",
@@ -645,7 +645,7 @@
"lists.edit": "Éditer la liste",
"lists.edit.submit": "Changer le titre",
"lists.new.create": "Ajouter une liste",
- "lists.new.create_title": "Create",
+ "lists.new.create_title": "Créer",
"lists.new.save_title": "Save Title",
"lists.new.title_placeholder": "Titre de la nouvelle liste",
"lists.search": "Rechercher parmi les gens que vous suivez",
diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts
index 87750381b..98d229f59 100644
--- a/app/soapbox/reducers/index.ts
+++ b/app/soapbox/reducers/index.ts
@@ -58,6 +58,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';
@@ -124,6 +125,7 @@ const reducers = {
rules,
history,
announcements,
+ 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..7beac7c43
--- /dev/null
+++ b/app/soapbox/reducers/tags.ts
@@ -0,0 +1,57 @@
+import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
+
+import { TAG_FETCH_FAIL, TAG_FETCH_REQUEST, TAG_FETCH_SUCCESS, TAG_FOLLOW_FAIL, TAG_FOLLOW_REQUEST, TAG_FOLLOW_SUCCESS, TAG_UNFOLLOW_FAIL, TAG_UNFOLLOW_REQUEST, TAG_UNFOLLOW_SUCCESS } from 'soapbox/actions/tags';
+import { normalizeTag } from 'soapbox/normalizers';
+
+import type { AnyAction } from 'redux';
+import type { APIEntity, Tag } from 'soapbox/types/entities';
+
+const TagRecord = ImmutableRecord({
+ list: ImmutableList(),
+ loading: true,
+});
+
+type State = ReturnType;
+
+const importTags = (state: State, tags: APIEntity[]): State => {
+ return state.withMutations((s) => {
+ s.set('list', ImmutableList(tags.map((tag) => normalizeTag(tag))));
+ s.set('loading', false);
+ });
+};
+
+const addTag = (state: State, tag: APIEntity): State => {
+ return state.withMutations((s) => {
+ s.set('list', state.list.push(normalizeTag(tag)));
+ s.set('loading', false);
+ });
+};
+
+const removeTag = (state: State, _tag: APIEntity): State => {
+ const tag = normalizeTag(_tag);
+ return state.withMutations((s) => {
+ s.set('list', state.list.filter((t) => t.name !== tag.name));
+ s.set('loading', false);
+ });
+};
+
+export default function filters(state = TagRecord(), action: AnyAction): State {
+ switch (action.type) {
+ case TAG_FETCH_REQUEST:
+ case TAG_FOLLOW_REQUEST:
+ case TAG_UNFOLLOW_REQUEST:
+ return state.set('loading', true);
+ case TAG_FETCH_SUCCESS:
+ return importTags(state, action.tags);
+ case TAG_FOLLOW_SUCCESS:
+ return addTag(state, action.tag);
+ case TAG_UNFOLLOW_SUCCESS:
+ return removeTag(state, action.tag);
+ case TAG_FETCH_FAIL:
+ case TAG_FOLLOW_FAIL:
+ case TAG_UNFOLLOW_FAIL:
+ return state.set('loading', false);
+ default:
+ return state;
+ }
+}
diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts
index 45b74dfe3..6f4064b70 100644
--- a/app/soapbox/utils/features.ts
+++ b/app/soapbox/utils/features.ts
@@ -341,6 +341,15 @@ const getInstanceFeatures = (instance: Instance) => {
(v.software === PLEROMA || v.software === AKKOMA),
]),
+ /**
+ * Can follow hashtag and show tags in home timeline
+ * @see GET /api/v1/followed_tags
+ */
+ followTags: any([
+ v.software === MASTODON && gte(v.compatVersion, '4.0.0'),
+ v.software === AKKOMA,
+ ]),
+
/**
* Whether client settings can be retrieved from the API.
* @see GET /api/pleroma/frontend_configurations