[ADD] Follow hashtags (#243)

This commit is contained in:
Clovis 2023-08-12 18:50:08 +02:00 committed by GitHub
parent 1d4a7c0d04
commit cd5cf427a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 482 additions and 137 deletions

2
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,2 @@
{
}

119
app/soapbox/actions/tags.ts Normal file
View File

@ -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,
};

View File

@ -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',

View File

@ -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 && (
<SidebarLink
to='/followed_hashtags'
icon={require('@tabler/icons/hash.svg')}
text={intl.formatMessage(messages.tags)}
onClick={onClose}
/>
)}
{features.profileDirectory && (
<SidebarLink
to='/directory'

View File

@ -85,7 +85,7 @@ interface ICardTitle {
/** A card's title. */
const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
<Text size='xl' weight='bold' tag='h1' data-testid='card-title' truncate>{title}</Text>
<Text className='grow' size='xl' weight='bold' tag='h1' data-testid='card-title' truncate>{title}</Text>
);
/** A card's body. */

View File

@ -10,7 +10,7 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** 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. */

View File

@ -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<IFollowButton> = ({ 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 ? (
<FormattedMessage id='hashtag_timeline.unfollow' defaultMessage='Unfollow' />
) : (
<FormattedMessage id='hashtag_timeline.follow' defaultMessage='Follow' />
);
}, [isFollow]);
return (
<IconButton className='mx-3' style={{ background: 'transparent' }} onClick={onClick} src={isFollow ? require('@tabler/icons/minus.svg') : require('@tabler/icons/plus.svg')} text={text} />
);
};
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 (
<Column label={intl.formatMessage(messages.heading)}>
{
!tags ? (
<Spinner />
) : (
<ScrollableList
className='flex flex-col gap-2'
scrollKey='followed_hashtags'
emptyMessage={<FormattedMessage id='column.tags.empty' defaultMessage="You don't follow any hashtag yet." />}
>
{
tags?.map((tag) => (
<div className='p-1 bg-gray-100 dark:bg-slate-900 rounded flex justify-between items-center'>
<div className='flex items-center'>
<Icon src={require('@tabler/icons/hash.svg')} />
<Text tag='span' weight='semibold'>
{ tag.name }
</Text>
</div>
<div className='flex items-center gap-3'>
<FollowButton id={tag.name} />
<span className='dark:text-slate-800 text-gray-300' >|</span>
<Button theme='link' to={`/tag/${tag.name}`}>
<div className='flex items-center'>
<FormattedMessage id='column.tags.see' defaultMessage='See' />
&nbsp;
<Icon src={require('@tabler/icons/arrow-right.svg')} />
</div>
</Button>
</div>
</div>
))
}
</ScrollableList>
)
}
</Column>
);
};
export default FollowedHashtags;

View File

@ -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(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />);
}
if (this.additionalFor('all')) {
title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />);
}
if (this.additionalFor('none')) {
title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />);
}
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 (
<Column label={`#${id}`} transparent withHeader={false}>
<div className='px-4 pt-4 sm:p-0'>
<ColumnHeader active={hasUnread} title={this.title()} />
</div>
<Timeline
scrollKey='hashtag_timeline'
timelineId={`hashtag:${id}`}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
divideType='space'
/>
</Column>
);
}
}

View File

@ -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<IFollowButton> = ({ 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 <FormattedMessage id='hashtag_timeline.loading' defaultMessage='Loading...' />;
return isFollow ? (
<FormattedMessage id='hashtag_timeline.unfollow' defaultMessage='Unfollow tag' />
) : (
<FormattedMessage id='hashtag_timeline.follow' defaultMessage='Follow this tag' />
);
}, [loading, isFollow]);
return (
<Button disabled={loading} theme={isFollow ? 'secondary' : 'primary'} size='sm' onClick={onClick}>
{
loading ? <Spinner withText={false} size={16} /> : <Icon src={isFollow ? require('@tabler/icons/minus.svg') : require('@tabler/icons/plus.svg')} className='mr-1' />
}
&nbsp;
{text}
</Button>
);
};
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(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: additionalFor('any') }} defaultMessage='or {additional}' />);
}
if (additionalFor('all')) {
t.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: additionalFor('all') }} defaultMessage='and {additional}' />);
}
if (additionalFor('none')) {
t.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: additionalFor('none') }} defaultMessage='without {additional}' />);
}
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 (
<Column label={`#${id}`} transparent withHeader={false}>
<div className='px-4 pt-4 sm:p-0'>
<ColumnHeader
active={hasUnread}
title={
<div className='flex justify-between items-center'>
{ title }
<FollowButton id={id} />
</div>
}
/>
</div>
<Timeline
scrollKey='hashtag_timeline'
timelineId={`hashtag:${id}`}
onLoadMore={handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
divideType='space'
/>
</Column>
);
};
export default HashtagTimeline;

View File

@ -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 && <WrappedRoute path='/chats/:chatId' page={DefaultPage} component={ChatRoom} content={children} />}
<WrappedRoute path='/follow_requests' page={DefaultPage} component={FollowRequests} content={children} />
<WrappedRoute path='/followed_hashtags' page={DefaultPage} component={FollowedHashtags} content={children} />
<WrappedRoute path='/blocks' page={DefaultPage} component={Blocks} content={children} />
{features.federating && <WrappedRoute path='/domain_blocks' page={DefaultPage} component={DomainBlocks} content={children} />}
<WrappedRoute path='/mutes' page={DefaultPage} component={Mutes} content={children} />
@ -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(() => {

View File

@ -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');
}

View File

@ -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",

View File

@ -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`

View File

@ -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<Tag>(),
loading: true,
});
type State = ReturnType<typeof TagRecord>;
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;
}
}

View File

@ -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