Filters expiration, restyle filters list, fix keywords deletion

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-filters-v2-hhz42m/deployments/2751
marcin mikołajczak 2023-03-05 19:49:40 +01:00
rodzic af314ee55d
commit 1d4d9c2732
6 zmienionych plików z 106 dodań i 97 usunięć

Wyświetl plik

@ -147,7 +147,7 @@ const fetchFilter = (id: string) =>
if (features.filters) return dispatch(fetchFilterV1(id)); if (features.filters) return dispatch(fetchFilterV1(id));
}; };
const createFilterV1 = (title: string, expires_at: string, context: Array<string>, hide: boolean, keywords: FilterKeywords) => const createFilterV1 = (title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_CREATE_REQUEST }); dispatch({ type: FILTERS_CREATE_REQUEST });
return api(getState).post('/api/v1/filters', { return api(getState).post('/api/v1/filters', {
@ -155,7 +155,7 @@ const createFilterV1 = (title: string, expires_at: string, context: Array<string
context, context,
irreversible: hide, irreversible: hide,
whole_word: keywords[0].whole_word, whole_word: keywords[0].whole_word,
expires_at, expires_in,
}).then(response => { }).then(response => {
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data });
toast.success(messages.added); toast.success(messages.added);
@ -164,14 +164,14 @@ const createFilterV1 = (title: string, expires_at: string, context: Array<string
}); });
}; };
const createFilterV2 = (title: string, expires_at: string, context: Array<string>, hide: boolean, keywords_attributes: FilterKeywords) => const createFilterV2 = (title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords_attributes: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_CREATE_REQUEST }); dispatch({ type: FILTERS_CREATE_REQUEST });
return api(getState).post('/api/v2/filters', { return api(getState).post('/api/v2/filters', {
title, title,
context, context,
filter_action: hide ? 'hide' : 'warn', filter_action: hide ? 'hide' : 'warn',
expires_at, expires_in,
keywords_attributes, keywords_attributes,
}).then(response => { }).then(response => {
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data });
@ -181,18 +181,18 @@ const createFilterV2 = (title: string, expires_at: string, context: Array<string
}); });
}; };
const createFilter = (title: string, expires_at: string, context: Array<string>, hide: boolean, keywords: FilterKeywords) => const createFilter = (title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const state = getState(); const state = getState();
const instance = state.instance; const instance = state.instance;
const features = getFeatures(instance); const features = getFeatures(instance);
if (features.filtersV2) return dispatch(createFilterV2(title, expires_at, context, hide, keywords)); if (features.filtersV2) return dispatch(createFilterV2(title, expires_in, context, hide, keywords));
return dispatch(createFilterV1(title, expires_at, context, hide, keywords)); return dispatch(createFilterV1(title, expires_in, context, hide, keywords));
}; };
const updateFilterV1 = (id: string, title: string, expires_at: string, context: Array<string>, hide: boolean, keywords: FilterKeywords) => const updateFilterV1 = (id: string, title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_UPDATE_REQUEST }); dispatch({ type: FILTERS_UPDATE_REQUEST });
return api(getState).patch(`/api/v1/filters/${id}`, { return api(getState).patch(`/api/v1/filters/${id}`, {
@ -200,7 +200,7 @@ const updateFilterV1 = (id: string, title: string, expires_at: string, context:
context, context,
irreversible: hide, irreversible: hide,
whole_word: keywords[0].whole_word, whole_word: keywords[0].whole_word,
expires_at, expires_in,
}).then(response => { }).then(response => {
dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data }); dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data });
toast.success(messages.added); toast.success(messages.added);
@ -209,14 +209,14 @@ const updateFilterV1 = (id: string, title: string, expires_at: string, context:
}); });
}; };
const updateFilterV2 = (id: string, title: string, expires_at: string, context: Array<string>, hide: boolean, keywords_attributes: FilterKeywords) => const updateFilterV2 = (id: string, title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords_attributes: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_UPDATE_REQUEST }); dispatch({ type: FILTERS_UPDATE_REQUEST });
return api(getState).patch(`/api/v2/filters/${id}`, { return api(getState).patch(`/api/v2/filters/${id}`, {
title, title,
context, context,
filter_action: hide ? 'hide' : 'warn', filter_action: hide ? 'hide' : 'warn',
expires_at, expires_in,
keywords_attributes, keywords_attributes,
}).then(response => { }).then(response => {
dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data }); dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data });
@ -226,15 +226,15 @@ const updateFilterV2 = (id: string, title: string, expires_at: string, context:
}); });
}; };
const updateFilter = (id: string, title: string, expires_at: string, context: Array<string>, hide: boolean, keywords: FilterKeywords) => const updateFilter = (id: string, title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const state = getState(); const state = getState();
const instance = state.instance; const instance = state.instance;
const features = getFeatures(instance); const features = getFeatures(instance);
if (features.filtersV2) return dispatch(updateFilterV2(id, title, expires_at, context, hide, keywords)); if (features.filtersV2) return dispatch(updateFilterV2(id, title, expires_in, context, hide, keywords));
return dispatch(updateFilterV1(id, title, expires_at, context, hide, keywords)); return dispatch(updateFilterV1(id, title, expires_in, context, hide, keywords));
}; };
const deleteFilterV1 = (id: string) => const deleteFilterV1 = (id: string) =>

Wyświetl plik

@ -70,7 +70,7 @@ const Streamfield: React.FC<IStreamfield> = ({
{(values.length > 0) && ( {(values.length > 0) && (
<Stack> <Stack>
{values.map((value, i) => ( {values.map((value, i) => value?._destroy ? null : (
<HStack space={2} alignItems='center'> <HStack space={2} alignItems='center'>
<Component key={i} onChange={handleChange(i)} value={value} /> <Component key={i} onChange={handleChange(i)} value={value} />
{values.length > minItems && onRemoveItem && ( {values.length > minItems && onRemoveItem && (

Wyświetl plik

@ -3,7 +3,7 @@ import { defineMessages, FormattedDate, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security'; import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security';
import { Button, Card, CardBody, CardHeader, CardTitle, Column, Spinner, Stack, Text } from 'soapbox/components/ui'; import { Button, Card, CardBody, CardHeader, CardTitle, Column, HStack, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { Token } from 'soapbox/reducers/security'; import { Token } from 'soapbox/reducers/security';
@ -59,12 +59,11 @@ const AuthToken: React.FC<IAuthToken> = ({ token, isCurrent }) => {
</Text> </Text>
)} )}
</Stack> </Stack>
<HStack justifyContent='end'>
<div className='flex justify-end'>
<Button theme={isCurrent ? 'danger' : 'primary'} onClick={handleRevoke}> <Button theme={isCurrent ? 'danger' : 'primary'} onClick={handleRevoke}>
{intl.formatMessage(messages.revoke)} {intl.formatMessage(messages.revoke)}
</Button> </Button>
</div> </HStack>
</Stack> </Stack>
</div> </div>
); );

Wyświetl plik

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
@ -10,11 +10,15 @@ import { useAppDispatch, useFeatures } from 'soapbox/hooks';
import { normalizeFilter } from 'soapbox/normalizers'; import { normalizeFilter } from 'soapbox/normalizers';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import { SelectDropdown } from '../forms';
import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield'; import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield';
interface IFilterField { interface IFilterField {
id?: string
keyword: string keyword: string
whole_word: boolean whole_word: boolean
_destroy?: boolean
} }
interface IEditFilter { interface IEditFilter {
@ -28,7 +32,6 @@ const messages = defineMessages({
keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' }, keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' },
keywords: { id: 'column.filters.keywords', defaultMessage: 'Keywords or phrases' }, keywords: { id: 'column.filters.keywords', defaultMessage: 'Keywords or phrases' },
expires: { id: 'column.filters.expires', defaultMessage: 'Expire after' }, expires: { id: 'column.filters.expires', defaultMessage: 'Expire after' },
expires_hint: { id: 'column.filters.expires_hint', defaultMessage: 'Expiration dates are not currently supported' },
home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' }, home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' },
public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' }, public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' },
notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' }, notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' },
@ -43,18 +46,15 @@ const messages = defineMessages({
add_new: { id: 'column.filters.add_new', defaultMessage: 'Add New Filter' }, add_new: { id: 'column.filters.add_new', defaultMessage: 'Add New Filter' },
edit: { id: 'column.filters.edit', defaultMessage: 'Edit Filter' }, edit: { id: 'column.filters.edit', defaultMessage: 'Edit Filter' },
create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' }, create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' },
expiration_never: { id: 'colum.filters.expiration.never', defaultMessage: 'Never' },
expiration_1800: { id: 'colum.filters.expiration.1800', defaultMessage: '30 minutes' },
expiration_3600: { id: 'colum.filters.expiration.3600', defaultMessage: '1 hour' },
expiration_21600: { id: 'colum.filters.expiration.21600', defaultMessage: '6 hours' },
expiration_43200: { id: 'colum.filters.expiration.43200', defaultMessage: '12 hours' },
expiration_86400: { id: 'colum.filters.expiration.86400', defaultMessage: '1 day' },
expiration_604800: { id: 'colum.filters.expiration.604800', defaultMessage: '1 week' },
}); });
// const expirations = {
// null: 'Never',
// // 1800: '30 minutes',
// // 3600: '1 hour',
// // 21600: '6 hour',
// // 43200: '12 hours',
// // 86400 : '1 day',
// // 604800: '1 week',
// };
const FilterField: StreamfieldComponent<IFilterField> = ({ value, onChange }) => { const FilterField: StreamfieldComponent<IFilterField> = ({ value, onChange }) => {
const intl = useIntl(); const intl = useIntl();
@ -95,18 +95,28 @@ const EditFilter: React.FC<IEditFilter> = ({ params }) => {
const [notFound, setNotFound] = useState(false); const [notFound, setNotFound] = useState(false);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [expiresAt] = useState(''); const [expiresIn, setExpiresIn] = useState<string | null>(null);
const [homeTimeline, setHomeTimeline] = useState(true); const [homeTimeline, setHomeTimeline] = useState(true);
const [publicTimeline, setPublicTimeline] = useState(false); const [publicTimeline, setPublicTimeline] = useState(false);
const [notifications, setNotifications] = useState(false); const [notifications, setNotifications] = useState(false);
const [conversations, setConversations] = useState(false); const [conversations, setConversations] = useState(false);
const [accounts, setAccounts] = useState(false); const [accounts, setAccounts] = useState(false);
const [hide, setHide] = useState(false); const [hide, setHide] = useState(false);
const [keywords, setKeywords] = useState<{ id?: string, keyword: string, whole_word: boolean }[]>([{ keyword: '', whole_word: false }]); const [keywords, setKeywords] = useState<IFilterField[]>([{ keyword: '', whole_word: false }]);
// const handleSelectChange = e => { const expirations = useMemo(() => ({
// this.setState({ [e.target.name]: e.target.value }); '': intl.formatMessage(messages.expiration_never),
// }; 1800: intl.formatMessage(messages.expiration_1800),
3600: intl.formatMessage(messages.expiration_3600),
21600: intl.formatMessage(messages.expiration_21600),
43200: intl.formatMessage(messages.expiration_43200),
86400: intl.formatMessage(messages.expiration_86400),
604800: intl.formatMessage(messages.expiration_604800),
}), []);
const handleSelectChange: React.ChangeEventHandler<HTMLSelectElement> = e => {
setExpiresIn(e.target.value);
};
const handleAddNew: React.FormEventHandler = e => { const handleAddNew: React.FormEventHandler = e => {
e.preventDefault(); e.preventDefault();
@ -129,8 +139,8 @@ const EditFilter: React.FC<IEditFilter> = ({ params }) => {
} }
dispatch(params.id dispatch(params.id
? updateFilter(params.id, title, expiresAt, context, hide, keywords) ? updateFilter(params.id, title, expiresIn, context, hide, keywords)
: createFilter(title, expiresAt, context, hide, keywords)).then(() => { : createFilter(title, expiresIn, context, hide, keywords)).then(() => {
history.push('/filters'); history.push('/filters');
}).catch(() => { }).catch(() => {
toast.error(intl.formatMessage(messages.create_error)); toast.error(intl.formatMessage(messages.create_error));
@ -141,7 +151,9 @@ const EditFilter: React.FC<IEditFilter> = ({ params }) => {
const handleAddKeyword = () => setKeywords(keywords => [...keywords, { keyword: '', whole_word: false }]); const handleAddKeyword = () => setKeywords(keywords => [...keywords, { keyword: '', whole_word: false }]);
const handleRemoveKeyword = (i: number) => setKeywords(keywords => keywords.filter((_, index) => index !== i)); const handleRemoveKeyword = (i: number) => setKeywords(keywords => keywords[i].id
? keywords.map((keyword, index) => index === i ? { ...keyword, _destroy: true } : keyword)
: keywords.filter((_, index) => index !== i));
useEffect(() => { useEffect(() => {
if (params.id) { if (params.id) {
@ -180,13 +192,16 @@ const EditFilter: React.FC<IEditFilter> = ({ params }) => {
onChange={({ target }) => setTitle(target.value)} onChange={({ target }) => setTitle(target.value)}
/> />
</FormGroup> </FormGroup>
{/* <FormGroup labelText={intl.formatMessage(messages.expires)} hintText={intl.formatMessage(messages.expires_hint)}>
{features.filtersExpiration && (
<FormGroup labelText={intl.formatMessage(messages.expires)}>
<SelectDropdown <SelectDropdown
items={expirations} items={expirations}
defaultValue={expirations.never} defaultValue={''}
onChange={this.handleSelectChange} onChange={handleSelectChange}
/> />
</FormGroup> */} </FormGroup>
)}
<Stack> <Stack>
<Text size='sm' weight='medium'> <Text size='sm' weight='medium'>

Wyświetl plik

@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom';
import { fetchFilters, deleteFilter } from 'soapbox/actions/filters'; import { fetchFilters, deleteFilter } from 'soapbox/actions/filters';
import ScrollableList from 'soapbox/components/scrollable-list'; import ScrollableList from 'soapbox/components/scrollable-list';
import { Button, CardTitle, Column, HStack, IconButton, Stack, Text } from 'soapbox/components/ui'; import { Button, CardTitle, Column, HStack, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
@ -31,6 +31,7 @@ const messages = defineMessages({
create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' }, create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' },
delete_error: { id: 'column.filters.delete_error', defaultMessage: 'Error deleting filter' }, delete_error: { id: 'column.filters.delete_error', defaultMessage: 'Error deleting filter' },
subheading_filters: { id: 'column.filters.subheading_filters', defaultMessage: 'Current Filters' }, subheading_filters: { id: 'column.filters.subheading_filters', defaultMessage: 'Current Filters' },
edit: { id: 'column.filters.edit', defaultMessage: 'Edit' },
delete: { id: 'column.filters.delete', defaultMessage: 'Delete' }, delete: { id: 'column.filters.delete', defaultMessage: 'Delete' },
}); });
@ -42,16 +43,6 @@ const contexts = {
account: messages.accounts, account: messages.accounts,
}; };
// const expirations = {
// null: 'Never',
// // 1800: '30 minutes',
// // 3600: '1 hour',
// // 21600: '6 hour',
// // 43200: '12 hours',
// // 86400 : '1 day',
// // 604800: '1 week',
// };
const Filters = () => { const Filters = () => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -94,10 +85,11 @@ const Filters = () => {
itemClassName='pb-4 last:pb-0' itemClassName='pb-4 last:pb-0'
> >
{filters.map((filter, i) => ( {filters.map((filter, i) => (
<HStack space={1}> <div className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
<Stack space={2}>
<Stack className='grow' space={1}> <Stack className='grow' space={1}>
<Text weight='medium'> <Text weight='medium'>
<FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' /> <FormattedMessage id='filters.filters_list_phrases_label' defaultMessage='Keywords or phrases:' />
{' '} {' '}
<Text theme='muted' tag='span'>{filter.keywords.map(keyword => keyword.keyword).join(', ')}</Text> <Text theme='muted' tag='span'>{filter.keywords.map(keyword => keyword.keyword).join(', ')}</Text>
</Text> </Text>
@ -119,19 +111,16 @@ const Filters = () => {
)} */} )} */}
</HStack> </HStack>
</Stack> </Stack>
<IconButton <HStack space={2} justifyContent='end'>
iconClassName='h-5 w-5 text-gray-700 hover:text-gray-800 dark:text-gray-600 dark:hover:text-gray-500' <Button theme='primary' onClick={handleFilterEdit(filter.id)}>
src={require('@tabler/icons/pencil.svg')} {intl.formatMessage(messages.edit)}
onClick={handleFilterEdit(filter.id)} </Button>
title={intl.formatMessage(messages.delete)} <Button theme='danger' onClick={handleFilterDelete(filter.id)}>
/> {intl.formatMessage(messages.delete)}
<IconButton </Button>
iconClassName='h-5 w-5 text-gray-700 hover:text-gray-800 dark:text-gray-600 dark:hover:text-gray-500'
src={require('@tabler/icons/trash.svg')}
onClick={handleFilterDelete(filter.id)}
title={intl.formatMessage(messages.delete)}
/>
</HStack> </HStack>
</Stack>
</div>
))} ))}
</ScrollableList> </ScrollableList>
</Column> </Column>

Wyświetl plik

@ -314,7 +314,7 @@ const getInstanceFeatures = (instance: Instance) => {
/** /**
* Mastodon's newer solution for direct messaging. * Mastodon's newer solution for direct messaging.
* @see {@link https://docs.joinmastodon.org/methods/timelines/conversations/} * @see {@link https://docs.joinmastodon.org/methods/conversations/}
*/ */
conversations: any([ conversations: any([
v.software === FRIENDICA, v.software === FRIENDICA,
@ -450,6 +450,12 @@ const getInstanceFeatures = (instance: Instance) => {
v.software === PLEROMA, v.software === PLEROMA,
]), ]),
/** Whether filters can automatically expires. */
filtersExpiration: any([
v.software === MASTODON,
v.software === PLEROMA && gte(v.version, '2.3.0'),
]),
/** /**
* Can edit and manage timeline filters (aka "muted words"). * Can edit and manage timeline filters (aka "muted words").
* @see {@link https://docs.joinmastodon.org/methods/filters/} * @see {@link https://docs.joinmastodon.org/methods/filters/}
@ -458,7 +464,7 @@ const getInstanceFeatures = (instance: Instance) => {
/** /**
* Allows setting the focal point of a media attachment. * Allows setting the focal point of a media attachment.
* @see {@link https://docs.joinmastodon.org/methods/statuses/media/} * @see {@link https://docs.joinmastodon.org/methods/media/}
*/ */
focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'), focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'),
@ -529,7 +535,7 @@ const getInstanceFeatures = (instance: Instance) => {
/** /**
* Can create, view, and manage lists. * Can create, view, and manage lists.
* @see {@link https://docs.joinmastodon.org/methods/timelines/lists/} * @see {@link https://docs.joinmastodon.org/methods/lists/}
* @see GET /api/v1/timelines/list/:list_id * @see GET /api/v1/timelines/list/:list_id
*/ */
lists: any([ lists: any([
@ -644,7 +650,7 @@ const getInstanceFeatures = (instance: Instance) => {
/** /**
* A directory of discoverable profiles from the instance. * A directory of discoverable profiles from the instance.
* @see {@link https://docs.joinmastodon.org/methods/instance/directory/} * @see {@link https://docs.joinmastodon.org/methods/directory/}
*/ */
profileDirectory: any([ profileDirectory: any([
v.software === FRIENDICA, v.software === FRIENDICA,
@ -736,7 +742,7 @@ const getInstanceFeatures = (instance: Instance) => {
/** /**
* Can schedule statuses to be posted at a later time. * Can schedule statuses to be posted at a later time.
* @see POST /api/v1/statuses * @see POST /api/v1/statuses
* @see {@link https://docs.joinmastodon.org/methods/statuses/scheduled_statuses/} * @see {@link https://docs.joinmastodon.org/methods/scheduled_statuses/}
*/ */
scheduledStatuses: any([ scheduledStatuses: any([
v.software === MASTODON && gte(v.version, '2.7.0'), v.software === MASTODON && gte(v.version, '2.7.0'),