Merge pull request #17 from mkljczk/statuses-visibility

This commit is contained in:
Marcin Mikołajczak 2024-07-26 22:08:28 +02:00 committed by GitHub
commit 9979932a43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 175 additions and 51 deletions

View file

@ -76,10 +76,10 @@ const getAuthApp = () =>
const createAuthApp = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
const params = {
client_name: sourceCode.displayName,
client_name: sourceCode.displayName,
redirect_uris: 'urn:ietf:wg:oauth:2.0:oob',
scopes: getScopes(getState()),
website: sourceCode.homepage,
scopes: getScopes(getState()),
website: sourceCode.homepage,
};
return dispatch(createApp(params)).then((app: Record<string, string>) =>

View file

@ -65,7 +65,7 @@ const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE' as const;
const COMPOSE_MODIFIED_LANGUAGE_CHANGE = 'COMPOSE_MODIFIED_LANGUAGE_CHANGE' as const;
const COMPOSE_LANGUAGE_ADD = 'COMPOSE_LANGUAGE_ADD' as const;
const COMPOSE_LANGUAGE_DELETE = 'COMPOSE_LANGUAGE_DELETE' as const;
const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE' as const;
const COMPOSE_FEDERATED_CHANGE = 'COMPOSE_FEDERATED_CHANGE' as const;
const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT' as const;
@ -387,6 +387,7 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) =>
scheduled_at: compose.schedule,
language: compose.language || compose.suggested_language,
to,
federated: compose.federated,
};
if (compose.language && compose.textMap.size) {
@ -937,6 +938,11 @@ const addSuggestedLanguage = (composeId: string, language: string) => ({
language,
});
const changeComposeFederated = (composeId: string) => ({
type: COMPOSE_FEDERATED_CHANGE,
id: composeId,
});
type ComposeAction =
ComposeSetStatusAction
| ReturnType<typeof changeCompose>
@ -989,6 +995,7 @@ type ComposeAction =
| ReturnType<typeof changeMediaOrder>
| ReturnType<typeof addSuggestedQuote>
| ReturnType<typeof addSuggestedLanguage>
| ReturnType<typeof changeComposeFederated>
export {
COMPOSE_CHANGE,
@ -1022,7 +1029,6 @@ export {
COMPOSE_MODIFIED_LANGUAGE_CHANGE,
COMPOSE_LANGUAGE_ADD,
COMPOSE_LANGUAGE_DELETE,
COMPOSE_LISTABILITY_CHANGE,
COMPOSE_EMOJI_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST,
COMPOSE_UPLOAD_CHANGE_SUCCESS,
@ -1043,6 +1049,7 @@ export {
COMPOSE_CHANGE_MEDIA_ORDER,
COMPOSE_ADD_SUGGESTED_QUOTE,
COMPOSE_ADD_SUGGESTED_LANGUAGE,
COMPOSE_FEDERATED_CHANGE,
setComposeToStatus,
changeCompose,
replyCompose,
@ -1104,5 +1111,6 @@ export {
changeMediaOrder,
addSuggestedQuote,
addSuggestedLanguage,
changeComposeFederated,
type ComposeAction,
};

View file

@ -4,12 +4,12 @@
* @see module:soapbox/actions/auth
*/
import api from 'soapbox/api';
import toast from 'soapbox/toast';
import { getLoggedInAccount } from 'soapbox/utils/auth';
import { GOTOSOCIAL, parseVersion } from 'soapbox/utils/features';
import { normalizeUsername } from 'soapbox/utils/input';
import api from '../api';
import { AUTH_LOGGED_OUT, messages } from './auth';
import type { AppDispatch, RootState } from 'soapbox/store';
@ -69,20 +69,39 @@ const revokeOAuthTokenById = (id: number) =>
const changePassword = (oldPassword: string, newPassword: string, confirmation: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: CHANGE_PASSWORD_REQUEST });
return api(getState)('/api/pleroma/change_password', {
method: 'POST',
body: JSON.stringify({
password: oldPassword,
new_password: newPassword,
new_password_confirmation: confirmation,
}),
}).then(response => {
if (response.json.error) throw response.json.error; // This endpoint returns HTTP 200 even on failure
dispatch({ type: CHANGE_PASSWORD_SUCCESS, response });
}).catch(error => {
dispatch({ type: CHANGE_PASSWORD_FAIL, error, skipAlert: true });
throw error;
});
const state = getState();
const instance = state.instance;
const v = parseVersion(instance.version);
if (v.software === GOTOSOCIAL) {
return api(getState)('/api/v1/user/password_change', {
method: 'POST',
body: JSON.stringify({
old_password: oldPassword,
new_password: newPassword,
}),
}).then(response => {
dispatch({ type: CHANGE_PASSWORD_SUCCESS, response });
}).catch(error => {
dispatch({ type: CHANGE_PASSWORD_FAIL, error, skipAlert: true });
throw error;
});
} else {
return api(getState)('/api/pleroma/change_password', {
method: 'POST',
body: JSON.stringify({
password: oldPassword,
new_password: newPassword,
new_password_confirmation: confirmation,
}),
}).then(response => {
if (response.json.error) throw response.json.error; // This endpoint returns HTTP 200 even on failure
dispatch({ type: CHANGE_PASSWORD_SUCCESS, response });
}).catch(error => {
dispatch({ type: CHANGE_PASSWORD_FAIL, error, skipAlert: true });
throw error;
});
}
};
const resetPassword = (usernameOrEmail: string) =>
@ -110,10 +129,14 @@ const resetPassword = (usernameOrEmail: string) =>
const changeEmail = (email: string, password: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: CHANGE_EMAIL_REQUEST, email });
return api(getState)('/api/pleroma/change_email', {
const state = getState();
const instance = state.instance;
const v = parseVersion(instance.version);
return api(getState)(v.software === GOTOSOCIAL ? '/api/v1/user/email_change' : '/api/pleroma/change_email', {
method: 'POST',
body: JSON.stringify({
email,
[v.software === GOTOSOCIAL ? 'new_email' : 'email']: email,
password,
}),
}).then(response => {
@ -127,10 +150,14 @@ const changeEmail = (email: string, password: string) =>
const deleteAccount = (password: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: CHANGE_PASSWORD_REQUEST });
const account = getLoggedInAccount(getState());
const state = getState();
const instance = state.instance;
const v = parseVersion(instance.version);
dispatch({ type: DELETE_ACCOUNT_REQUEST });
return api(getState)('/api/pleroma/delete_account', {
return api(getState)(v.software === GOTOSOCIAL ? '/api/v1/accounts/delete' : '/api/pleroma/delete_account', {
method: 'POST',
body: JSON.stringify({ password }),
}).then(response => {

View file

@ -463,7 +463,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
icon: status.pinned ? require('@tabler/icons/outline/pinned-off.svg') : require('@tabler/icons/outline/pin.svg'),
});
} else {
if (status.visibility === 'private') {
if (status.visibility === 'private' || status.visibility === 'mutuals_only') {
menu.push({
text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog_private),
action: handleReblogClick,
@ -656,7 +656,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
if (status.visibility === 'direct') {
reblogIcon = require('@tabler/icons/outline/mail.svg');
} else if (status.visibility === 'private') {
} else if (status.visibility === 'private' || status.visibility === 'mutuals_only') {
reblogIcon = require('@tabler/icons/outline/lock.svg');
}

View file

@ -78,7 +78,7 @@ const LoginPage = () => {
<LoginForm handleSubmit={handleSubmit} isLoading={isLoading} />
<ConsumersList />
<div className={'flex items-center gap-2.5 before:flex-1 before:border-b before:border-gray-300 before:content-[\'\'] after:flex-1 after:border-b after:border-gray-300 after:content-[\'\'] before:black:border-gray-800 after:black:border-gray-800 before:dark:border-gray-600 after:dark:border-gray-600'}>
<div className={'flex items-center gap-2.5 before:flex-1 before:border-b before:border-gray-300 before:content-[\'\'] after:flex-1 after:border-b after:border-gray-300 after:content-[\'\'] before:dark:border-gray-800 after:dark:border-gray-800'}>
<Text align='center'>
<FormattedMessage id='login_form.divider' defaultMessage='or' />
</Text>

View file

@ -229,7 +229,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
} else if (privacy === 'direct') {
publishIcon = require('@tabler/icons/outline/mail.svg');
publishText = intl.formatMessage(messages.message);
} else if (privacy === 'private') {
} else if (privacy === 'private' || privacy === 'mutuals_only') {
publishIcon = require('@tabler/icons/outline/lock.svg');
publishText = intl.formatMessage(messages.publish);
} else {

View file

@ -1,17 +1,18 @@
import clsx from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import React, { useState, useRef, useEffect } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { spring } from 'react-motion';
// @ts-ignore
import Overlay from 'react-overlays/lib/Overlay';
import { changeComposeVisibility } from 'soapbox/actions/compose';
import { changeComposeFederated, changeComposeVisibility } from 'soapbox/actions/compose';
import { closeModal, openModal } from 'soapbox/actions/modals';
import Icon from 'soapbox/components/icon';
import { Button } from 'soapbox/components/ui';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import { Button, Toggle } from 'soapbox/components/ui';
import { useAppDispatch, useCompose, useFeatures, useInstance } from 'soapbox/hooks';
import { userTouching } from 'soapbox/is-mobile';
import { GOTOSOCIAL, parseVersion, PLEROMA } from 'soapbox/utils/features';
import Motion from '../../ui/util/optional-motion';
@ -22,13 +23,26 @@ const messages = defineMessages({
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not post to public timelines' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
mutuals_only_short: { id: 'privacy.mutuals_only.short', defaultMessage: 'Mutuals-only' },
mutuals_only_long: { id: 'privacy.mutuals_only.long', defaultMessage: 'Post to mutually followed users only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
local_short: { id: 'privacy.local.short', defaultMessage: 'Local-only' },
local_long: { id: 'privacy.local.long', defaultMessage: 'Only visible on your instance' },
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust post privacy' },
local: { id: 'privacy.local', defaultMessage: '{privacy} (local-only)' },
});
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
interface Option {
icon: string;
value: string;
text: string;
meta: string;
}
interface IPrivacyDropdownMenu {
style?: React.CSSProperties;
items: any[];
@ -37,9 +51,14 @@ interface IPrivacyDropdownMenu {
onClose: () => void;
onChange: (value: string | null) => void;
unavailable?: boolean;
showFederated?: boolean;
federated?: boolean;
onChangeFederated: () => void;
}
const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, placement, value, onClose, onChange }) => {
const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({
style, items, placement, value, onClose, onChange, showFederated, federated, onChangeFederated,
}) => {
const node = useRef<HTMLDivElement>(null);
const focusedItem = useRef<HTMLDivElement>(null);
@ -52,9 +71,7 @@ const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, pla
};
const handleKeyDown: React.KeyboardEventHandler = e => {
const value = e.currentTarget.getAttribute('data-index');
const index = items.findIndex(item => item.value === value);
let element: ChildNode | null | undefined = null;
const index = [...e.currentTarget.parentElement!.children].indexOf(e.currentTarget); let element: ChildNode | null | undefined = null;
switch (e.key) {
case 'Escape':
@ -86,7 +103,8 @@ const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, pla
if (element) {
(element as HTMLElement).focus();
onChange((element as HTMLElement).getAttribute('data-index'));
const value = (element as HTMLElement).getAttribute('data-index');
if (value !== 'local_switch') onChange(value);
e.preventDefault();
e.stopPropagation();
}
@ -97,8 +115,11 @@ const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, pla
e.preventDefault();
onClose();
onChange(value);
if (value === 'local_switch') onChangeFederated();
else {
onClose();
onChange(value);
}
};
useEffect(() => {
@ -143,7 +164,7 @@ const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, pla
onKeyDown={handleKeyDown}
onClick={handleClick}
className={clsx(
'flex cursor-pointer p-2.5 text-sm text-gray-700 hover:bg-gray-100 black:hover:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800',
'flex cursor-pointer items-center p-2.5 text-gray-700 hover:bg-gray-100 black:hover:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800',
{ 'bg-gray-100 dark:bg-gray-800 black:bg-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700': active },
)}
aria-selected={active}
@ -154,16 +175,41 @@ const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, pla
</div>
<div
className={clsx('flex-auto text-primary-600 dark:text-primary-400', {
className={clsx('flex-auto text-xs text-primary-600 dark:text-primary-400', {
'text-black dark:text-white': active,
})}
>
<strong className='block font-medium text-black dark:text-white'>{item.text}</strong>
<strong className='block text-sm font-medium text-black dark:text-white'>{item.text}</strong>
{item.meta}
</div>
</div>
);
})}
{showFederated && (
<div
role='option'
tabIndex={0}
data-index='local_switch'
onKeyDown={handleKeyDown}
onClick={onChangeFederated}
className='flex cursor-pointer items-center p-2.5 text-xs text-gray-700 hover:bg-gray-100 focus:bg-gray-100 black:hover:bg-gray-900 black:focus:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:focus:bg-gray-800'
>
<div className='mr-2.5 flex items-center justify-center rtl:ml-2.5 rtl:mr-0'>
<Icon src={require('@tabler/icons/outline/affiliate.svg')} />
</div>
<div
className='flex-auto text-xs text-primary-600 dark:text-primary-400'
>
<strong className='block text-sm font-medium text-black focus:text-black dark:text-white dark:focus:text-primary-400'>
<FormattedMessage id='privacy.local.short' defaultMessage='Local-only' />
</strong>
<FormattedMessage id='privacy.local.long' defaultMessage='Only visible on your instance' />
</div>
<Toggle checked={!federated} onChange={onChangeFederated} />
</div>
)}
</div>
)}
</Motion>
@ -181,6 +227,10 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
const intl = useIntl();
const node = useRef<HTMLDivElement>(null);
const activeElement = useRef<HTMLElement | null>(null);
const instance = useInstance();
const features = useFeatures();
const v = parseVersion(instance.version);
const compose = useCompose(composeId);
@ -194,11 +244,15 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
{ icon: require('@tabler/icons/outline/world.svg'), value: 'public', text: intl.formatMessage(messages.public_short), meta: intl.formatMessage(messages.public_long) },
{ icon: require('@tabler/icons/outline/lock-open.svg'), value: 'unlisted', text: intl.formatMessage(messages.unlisted_short), meta: intl.formatMessage(messages.unlisted_long) },
{ icon: require('@tabler/icons/outline/lock.svg'), value: 'private', text: intl.formatMessage(messages.private_short), meta: intl.formatMessage(messages.private_long) },
features.mutualsOnlyStatuses ? { icon: require('@tabler/icons/outline/users-group.svg'), value: 'mutuals_only', text: intl.formatMessage(messages.mutuals_only_short), meta: intl.formatMessage(messages.mutuals_only_long) } : undefined,
{ icon: require('@tabler/icons/outline/mail.svg'), value: 'direct', text: intl.formatMessage(messages.direct_short), meta: intl.formatMessage(messages.direct_long) },
];
features.localOnlyStatuses && v.software === PLEROMA ? { icon: require('@tabler/icons/outline/affiliate.svg'), value: 'local', text: intl.formatMessage(messages.local_short), meta: intl.formatMessage(messages.local_long) } : undefined,
].filter((option): option is Option => !!option);
const onChange = (value: string | null) => value && dispatch(changeComposeVisibility(composeId, value));
const onChangeFederated = () => dispatch(changeComposeFederated(composeId));
const onModalOpen = (props: Record<string, any>) => dispatch(openModal('ACTIONS', props));
const onModalClose = () => dispatch(closeModal('ACTIONS'));
@ -280,7 +334,9 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
<Button
theme='muted'
size='xs'
text={valueOption?.text}
text={compose.federated ? valueOption?.text : intl.formatMessage(messages.local, {
privacy: valueOption?.text,
})}
icon={valueOption?.icon}
secondaryIcon={require('@tabler/icons/outline/chevron-down.svg')}
title={intl.formatMessage(messages.change_privacy)}
@ -297,6 +353,9 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
onClose={handleClose}
onChange={onChange}
placement={placement}
showFederated={features.localOnlyStatuses && v.software === GOTOSOCIAL}
federated={compose.federated}
onChangeFederated={onChangeFederated}
/>
</Overlay>
</div>

View file

@ -11,7 +11,7 @@ interface IWarning {
const Warning: React.FC<IWarning> = ({ message }) => (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<div className='mb-2.5 rounded bg-accent-300 px-2.5 py-2 text-xs text-white shadow-md' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
<div className='mb-2.5 rounded border border-solid border-gray-400 bg-transparent px-2.5 py-2 text-xs text-gray-900 dark:border-gray-800 dark:text-white' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
{message}
</div>
)}

View file

@ -16,7 +16,7 @@ interface IWarningWrapper {
const WarningWrapper: React.FC<IWarningWrapper> = ({ composeId }) => {
const compose = useCompose(composeId);
const needsLockWarning = useAppSelector((state) => compose.privacy === 'private' && !selectOwnAccount(state)!.locked);
const needsLockWarning = useAppSelector((state) => (compose.privacy === 'private' || compose.privacy === 'mutuals_only') && !selectOwnAccount(state)!.locked);
const hashtagWarning = (compose.privacy !== 'public' && compose.privacy !== 'group') && APPROX_HASHTAG_RE.test(compose.text);
const directMessageWarning = compose.privacy === 'direct';

View file

@ -104,7 +104,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
if (actualStatus.visibility === 'direct') {
statusTypeIcon = <Icon className='h-4 w-4 text-gray-700 dark:text-gray-600' src={require('@tabler/icons/outline/mail.svg')} />;
} else if (actualStatus.visibility === 'private') {
} else if (actualStatus.visibility === 'private' || actualStatus.visibility === 'mutuals_only') {
statusTypeIcon = <Icon className='h-4 w-4 text-gray-700 dark:text-gray-600' src={require('@tabler/icons/outline/lock.svg')} />;
}

View file

@ -1199,6 +1199,11 @@
"privacy.change": "Adjust post privacy",
"privacy.direct.long": "Post to mentioned users only",
"privacy.direct.short": "Direct",
"privacy.local": "{privacy} (local-only)",
"privacy.local.long": "Only visible on your instance",
"privacy.local.short": "Local-only",
"privacy.mutuals_only.long": "Post to mutually followed users only",
"privacy.mutuals_only.short": "Mutuals-only",
"privacy.private.long": "Post to followers only",
"privacy.private.short": "Followers-only",
"privacy.public.long": "Post to public timelines",

View file

@ -1301,7 +1301,7 @@
"security.fields.new_password.label": "Nowe hasło",
"security.fields.old_password.label": "Obecne hasło",
"security.fields.password.label": "Hasło",
"security.fields.password_confirmation.label": "Obecne hasło (ponownie)",
"security.fields.password_confirmation.label": "Nowe hasło (ponownie)",
"security.headers.delete": "Usuń konto",
"security.headers.tokens": "Sesje",
"security.qr.fail": "Nie udało się uzyskać klucza konfiguracyjnego",

View file

@ -20,7 +20,7 @@ import { maybeFromJS } from 'soapbox/utils/normalizers';
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity, EmojiReaction } from 'soapbox/types/entities';
type StatusApprovalStatus = 'pending' | 'approval' | 'rejected';
type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'group';
type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'group' | 'mutuals_only' | 'local';
type EventJoinMode = 'free' | 'restricted' | 'invite';
type EventJoinState = 'pending' | 'reject' | 'accept';

View file

@ -59,6 +59,7 @@ import {
COMPOSE_EDITOR_STATE_SET,
COMPOSE_CHANGE_MEDIA_ORDER,
COMPOSE_ADD_SUGGESTED_QUOTE,
COMPOSE_FEDERATED_CHANGE,
ComposeAction,
} from '../actions/compose';
import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET, type EventsAction } from '../actions/events';
@ -124,6 +125,7 @@ const ReducerCompose = ImmutableRecord({
language: null as Language | null,
modified_language: null as Language | null,
suggested_language: null as string | null,
federated: true,
});
type State = ImmutableMap<string, Compose>;
@ -226,7 +228,7 @@ const insertEmoji = (compose: Compose, position: number, emojiData: Emoji, needs
};
const privacyPreference = (a: string, b: string) => {
const order = ['public', 'unlisted', 'private', 'direct'];
const order = ['public', 'unlisted', 'mutuals_only', 'private', 'direct', 'local'];
if (a === 'group') return a;
@ -602,6 +604,8 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | Me
return updateCompose(state, action.id, compose => compose
.update('dismissed_quotes', quotes => compose.quote ? quotes.add(compose.quote) : quotes)
.set('quote', null));
case COMPOSE_FEDERATED_CHANGE:
return updateCompose(state, action.id, compose => compose.update('federated', value => !value));
default:
return state;
}

View file

@ -634,6 +634,15 @@ const getInstanceFeatures = (instance: Instance) => {
v.software === GOTOSOCIAL,
]),
/**
* Ability to post statuses that don't federate.
* @see POST /api/v1/statuses
*/
localOnlyStatuses: federation.enabled && any([
v.software === GOTOSOCIAL,
v.software === PLEROMA,
]),
/**
* Can sign in using username instead of e-mail address.
*/
@ -699,6 +708,12 @@ const getInstanceFeatures = (instance: Instance) => {
v.software === GOTOSOCIAL,
]),
/**
* Ability to post statuses only to accounts with mutual relationship.
* @see POST /api/v1/statuses
*/
mutualsOnlyStatuses: v.software === GOTOSOCIAL,
/**
* Add private notes to accounts.
* @see POST /api/v1/accounts/:id/note
@ -877,8 +892,14 @@ const getInstanceFeatures = (instance: Instance) => {
* @see POST /api/pleroma/change_password
* @see POST /api/pleroma/change_email
* @see POST /api/pleroma/delete_account
* @see POST /api/v1/user/email_change
* @see POST /api/v1/user/password_change
* @see POST /api/v1/accounts/delete_account
*/
security: v.software === PLEROMA,
security: any([
v.software === PLEROMA,
v.software === GOTOSOCIAL,
]),
/**
* Ability to manage account sessions.