Add ability to follow hashtags in web UI
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
cb5702db15
commit
93b09d8206
8 changed files with 218 additions and 11 deletions
120
app/soapbox/actions/tags.ts
Normal file
120
app/soapbox/actions/tags.ts
Normal file
|
@ -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,
|
||||||
|
};
|
|
@ -45,6 +45,12 @@ interface ICardHeader {
|
||||||
backHref?: string,
|
backHref?: string,
|
||||||
onBackClick?: (event: React.MouseEvent) => void
|
onBackClick?: (event: React.MouseEvent) => void
|
||||||
className?: string
|
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
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,7 +58,7 @@ interface ICardHeader {
|
||||||
* Card header container with back button.
|
* Card header container with back button.
|
||||||
* Typically holds a CardTitle.
|
* Typically holds a CardTitle.
|
||||||
*/
|
*/
|
||||||
const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBackClick }): JSX.Element => {
|
const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBackClick, onActionClick, actionIcon, actionTitle }): JSX.Element => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const renderBackButton = () => {
|
const renderBackButton = () => {
|
||||||
|
@ -64,7 +70,7 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
|
||||||
const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick };
|
const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp {...backAttributes} className='text-gray-900 dark:text-gray-100 focus:ring-primary-500 focus:ring-2' aria-label={intl.formatMessage(messages.back)}>
|
<Comp {...backAttributes} className='p-0.5 -m-0.5 text-gray-900 dark:text-gray-100 focus:ring-primary-500 focus:ring-2 rounded-full' aria-label={intl.formatMessage(messages.back)}>
|
||||||
<SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6 rtl:rotate-180' />
|
<SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6 rtl:rotate-180' />
|
||||||
<span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
|
<span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
|
||||||
</Comp>
|
</Comp>
|
||||||
|
@ -76,6 +82,12 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
|
||||||
{renderBackButton()}
|
{renderBackButton()}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
|
{onActionClick && actionIcon && (
|
||||||
|
<button className='p-0.5 -m-0.5 text-gray-900 dark:text-gray-100 focus:ring-primary-500 focus:ring-2 rounded-full' onClick={onActionClick} title={actionTitle}>
|
||||||
|
<SvgIcon src={actionIcon} className='h-6 w-6' />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -86,7 +98,7 @@ interface ICardTitle {
|
||||||
|
|
||||||
/** A card's title. */
|
/** A card's title. */
|
||||||
const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
|
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>
|
||||||
);
|
);
|
||||||
|
|
||||||
interface ICardBody {
|
interface ICardBody {
|
||||||
|
|
|
@ -7,10 +7,10 @@ import { useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
|
||||||
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
|
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
|
||||||
|
|
||||||
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' |'className'>;
|
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' | 'className' | 'onActionClick' | 'actionIcon' | 'actionTitle'>;
|
||||||
|
|
||||||
/** Contains the column title with optional back button. */
|
/** Contains the column title with optional back button. */
|
||||||
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) => {
|
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className, onActionClick, actionIcon, actionTitle }) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = () => {
|
||||||
|
@ -27,7 +27,13 @@ const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) =
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardHeader className={className} onBackClick={handleBackClick}>
|
<CardHeader
|
||||||
|
className={className}
|
||||||
|
onBackClick={handleBackClick}
|
||||||
|
onActionClick={onActionClick}
|
||||||
|
actionIcon={actionIcon}
|
||||||
|
actionTitle={actionTitle}
|
||||||
|
>
|
||||||
<CardTitle title={label} />
|
<CardTitle title={label} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
);
|
);
|
||||||
|
@ -46,13 +52,19 @@ export interface IColumn {
|
||||||
className?: string,
|
className?: string,
|
||||||
/** Ref forwarded to column. */
|
/** Ref forwarded to column. */
|
||||||
ref?: React.Ref<HTMLDivElement>
|
ref?: React.Ref<HTMLDivElement>
|
||||||
|
/** 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 to display in the column. */
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A backdrop for the main section of the UI. */
|
/** A backdrop for the main section of the UI. */
|
||||||
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
|
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): 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();
|
const soapboxConfig = useSoapboxConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -75,6 +87,9 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
|
||||||
label={label}
|
label={label}
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
className={classNames({ 'px-4 pt-4 sm:p-0': transparent })}
|
className={classNames({ 'px-4 pt-4 sm:p-0': transparent })}
|
||||||
|
onActionClick={onActionClick}
|
||||||
|
actionIcon={actionIcon}
|
||||||
|
actionTitle={actionTitle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,11 @@ import React, { useEffect, useRef } from 'react';
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import { connectHashtagStream } from 'soapbox/actions/streaming';
|
import { connectHashtagStream } from 'soapbox/actions/streaming';
|
||||||
|
import { fetchHashtag, followHashtag, unfollowHashtag } from 'soapbox/actions/tags';
|
||||||
import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines';
|
import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines';
|
||||||
import { Column } from 'soapbox/components/ui';
|
import { Column } from 'soapbox/components/ui';
|
||||||
import Timeline from 'soapbox/features/ui/components/timeline';
|
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';
|
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}' },
|
any: { id: 'hashtag.column_header.tag_mode.any', defaultMessage: 'or {additional}' },
|
||||||
all: { id: 'hashtag.column_header.tag_mode.all', defaultMessage: 'and {additional}' },
|
all: { id: 'hashtag.column_header.tag_mode.all', defaultMessage: 'and {additional}' },
|
||||||
none: { id: 'hashtag.column_header.tag_mode.none', defaultMessage: 'without {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.' },
|
empty: { id: 'empty_column.hashtag', defaultMessage: 'There is nothing in this hashtag yet.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -32,9 +35,11 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const id = params?.id || '';
|
const id = params?.id || '';
|
||||||
const tags = params?.tags || { any: [], all: [], none: [] };
|
const tags = params?.tags || { any: [], all: [], none: [] };
|
||||||
|
|
||||||
|
const features = useFeatures();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const disconnects = useRef<(() => void)[]>([]);
|
const disconnects = useRef<(() => void)[]>([]);
|
||||||
|
const tag = useAppSelector((state) => state.tags.get(id));
|
||||||
|
|
||||||
// Mastodon supports displaying results from multiple hashtags.
|
// Mastodon supports displaying results from multiple hashtags.
|
||||||
// https://github.com/mastodon/mastodon/issues/6359
|
// https://github.com/mastodon/mastodon/issues/6359
|
||||||
|
@ -88,9 +93,18 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
||||||
dispatch(expandHashtagTimeline(id, { maxId, tags }));
|
dispatch(expandHashtagTimeline(id, { maxId, tags }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFollow = () => {
|
||||||
|
if (tag?.following) {
|
||||||
|
dispatch(unfollowHashtag(id));
|
||||||
|
} else {
|
||||||
|
dispatch(followHashtag(id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
subscribe();
|
subscribe();
|
||||||
dispatch(expandHashtagTimeline(id, { tags }));
|
dispatch(expandHashtagTimeline(id, { tags }));
|
||||||
|
dispatch(fetchHashtag(id));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
@ -105,7 +119,13 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={title()} transparent>
|
<Column
|
||||||
|
label={title()}
|
||||||
|
transparent
|
||||||
|
onActionClick={features.followHashtags ? handleFollow : undefined}
|
||||||
|
actionIcon={tag?.following ? require('@tabler/icons/bell-ringing.svg') : require('@tabler/icons/bell.svg')}
|
||||||
|
actionTitle={intl.formatMessage(tag?.following ? messages.unfollowHashtag : messages.followHashtag)}
|
||||||
|
>
|
||||||
<Timeline
|
<Timeline
|
||||||
scrollKey='hashtag_timeline'
|
scrollKey='hashtag_timeline'
|
||||||
timelineId={`hashtag:${id}`}
|
timelineId={`hashtag:${id}`}
|
||||||
|
@ -117,4 +137,4 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HashtagTimeline;
|
export default HashtagTimeline;
|
||||||
|
|
|
@ -19,6 +19,7 @@ export const TagRecord = ImmutableRecord({
|
||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
history: null as ImmutableList<History> | null,
|
history: null as ImmutableList<History> | null,
|
||||||
|
following: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizeHistoryList = (tag: ImmutableMap<string, any>) => {
|
const normalizeHistoryList = (tag: ImmutableMap<string, any>) => {
|
||||||
|
|
|
@ -56,6 +56,7 @@ import status_hover_card from './status-hover-card';
|
||||||
import status_lists from './status-lists';
|
import status_lists from './status-lists';
|
||||||
import statuses from './statuses';
|
import statuses from './statuses';
|
||||||
import suggestions from './suggestions';
|
import suggestions from './suggestions';
|
||||||
|
import tags from './tags';
|
||||||
import timelines from './timelines';
|
import timelines from './timelines';
|
||||||
import trending_statuses from './trending-statuses';
|
import trending_statuses from './trending-statuses';
|
||||||
import trends from './trends';
|
import trends from './trends';
|
||||||
|
@ -120,6 +121,7 @@ const reducers = {
|
||||||
announcements,
|
announcements,
|
||||||
compose_event,
|
compose_event,
|
||||||
admin_user_index,
|
admin_user_index,
|
||||||
|
tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build a default state from all reducers: it has the key and `undefined`
|
// Build a default state from all reducers: it has the key and `undefined`
|
||||||
|
|
30
app/soapbox/reducers/tags.ts
Normal file
30
app/soapbox/reducers/tags.ts
Normal file
|
@ -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<string, Tag>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -447,6 +447,13 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
*/
|
*/
|
||||||
focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'),
|
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.
|
* Ability to lock accounts and manually approve followers.
|
||||||
* @see PATCH /api/v1/accounts/update_credentials
|
* @see PATCH /api/v1/accounts/update_credentials
|
||||||
|
|
Loading…
Reference in a new issue