Merge branch 'instance-zod' into 'main'

Convert instance to use zod

See merge request soapbox-pub/soapbox!2751
environments/review-main-yi2y9f/deployments/4041
Alex Gleason 1 year ago
commit aabaaee8b8

@ -1,11 +1,11 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { buildRelationship } from 'soapbox/jest/factory';
import { buildInstance, buildRelationship } from 'soapbox/jest/factory';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { ListRecord, ReducerRecord } from 'soapbox/reducers/user-lists';
import { normalizeAccount, normalizeInstance } from '../../normalizers';
import { normalizeAccount } from '../../normalizers';
import {
authorizeFollowRequest,
blockAccount,
@ -190,13 +190,13 @@ describe('fetchAccountByUsername()', () => {
describe('when "accountByUsername" feature is enabled', () => {
beforeEach(() => {
const state = rootState
.set('instance', normalizeInstance({
.set('instance', buildInstance({
version: '2.7.2 (compatible; Pleroma 2.4.52-1337-g4779199e.gleasonator+soapbox)',
pleroma: ImmutableMap({
metadata: ImmutableMap({
pleroma: {
metadata: {
features: [],
}),
}),
},
},
}))
.set('me', '123');
store = mockStore(state);
@ -253,13 +253,13 @@ describe('fetchAccountByUsername()', () => {
describe('when "accountLookup" feature is enabled', () => {
beforeEach(() => {
const state = rootState
.set('instance', normalizeInstance({
.set('instance', buildInstance({
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
pleroma: ImmutableMap({
metadata: ImmutableMap({
pleroma: {
metadata: {
features: [],
}),
}),
},
},
}))
.set('me', '123');
store = mockStore(state);

@ -2,8 +2,9 @@ import { List as ImmutableList } from 'immutable';
import { fetchAnnouncements, dismissAnnouncement, addReaction, removeReaction } from 'soapbox/actions/announcements';
import { __stub } from 'soapbox/api';
import { buildInstance } from 'soapbox/jest/factory';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { normalizeAnnouncement, normalizeInstance } from 'soapbox/normalizers';
import { normalizeAnnouncement } from 'soapbox/normalizers';
import type { APIEntity } from 'soapbox/types/entities';
@ -13,7 +14,7 @@ describe('fetchAnnouncements()', () => {
describe('with a successful API request', () => {
it('should fetch announcements from the API', async() => {
const state = rootState
.set('instance', normalizeInstance({ version: '3.5.3' }));
.set('instance', buildInstance({ version: '3.5.3' }));
const store = mockStore(state);
__stub((mock) => {

@ -1,7 +1,7 @@
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import { buildInstance } from 'soapbox/jest/factory';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { InstanceRecord } from 'soapbox/normalizers';
import { ReducerCompose } from 'soapbox/reducers/compose';
import { uploadCompose, submitCompose } from '../compose';
@ -14,15 +14,15 @@ describe('uploadCompose()', () => {
let files: FileList, store: ReturnType<typeof mockStore>;
beforeEach(() => {
const instance = InstanceRecord({
configuration: ImmutableMap({
statuses: ImmutableMap({
const instance = buildInstance({
configuration: {
statuses: {
max_media_attachments: 4,
}),
media_attachments: ImmutableMap({
},
media_attachments: {
image_size_limit: 10,
}),
}),
},
},
});
const state = rootState
@ -60,15 +60,15 @@ describe('uploadCompose()', () => {
let files: FileList, store: ReturnType<typeof mockStore>;
beforeEach(() => {
const instance = InstanceRecord({
configuration: ImmutableMap({
statuses: ImmutableMap({
const instance = buildInstance({
configuration: {
statuses: {
max_media_attachments: 4,
}),
media_attachments: ImmutableMap({
},
media_attachments: {
video_size_limit: 10,
}),
}),
},
},
});
const state = rootState

@ -393,7 +393,7 @@ const submitComposeFail = (composeId: string, error: AxiosError) => ({
const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const attachmentLimit = getState().instance.configuration.getIn(['statuses', 'max_media_attachments']) as number;
const attachmentLimit = getState().instance.configuration.statuses.max_media_attachments;
const media = getState().compose.get(composeId)?.media_attachments;
const progress = new Array(files.length).fill(0);

@ -9,7 +9,7 @@
import { createApp } from 'soapbox/actions/apps';
import { authLoggedIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth';
import { obtainOAuthToken } from 'soapbox/actions/oauth';
import { normalizeInstance } from 'soapbox/normalizers';
import { instanceSchema, type Instance } from 'soapbox/schemas';
import { parseBaseURL } from 'soapbox/utils/auth';
import sourceCode from 'soapbox/utils/code';
import { getQuirks } from 'soapbox/utils/quirks';
@ -18,17 +18,16 @@ import { getInstanceScopes } from 'soapbox/utils/scopes';
import { baseClient } from '../api';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Instance } from 'soapbox/types/entities';
const fetchExternalInstance = (baseURL?: string) => {
return baseClient(null, baseURL)
.get('/api/v1/instance')
.then(({ data: instance }) => normalizeInstance(instance))
.then(({ data: instance }) => instanceSchema.parse(instance))
.catch(error => {
if (error.response?.status === 401) {
// Authenticated fetch is enabled.
// Continue with a limited featureset.
return normalizeInstance({});
return instanceSchema.parse({});
} else {
throw error;
}

@ -65,9 +65,9 @@ const uploadFile = (
) =>
async (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined;
const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined;
const maxVideoDuration = getState().instance.configuration.getIn(['media_attachments', 'video_duration_limit']) as number | undefined;
const maxImageSize = getState().instance.configuration.media_attachments.image_size_limit;
const maxVideoSize = getState().instance.configuration.media_attachments.video_size_limit;
const maxVideoDuration = getState().instance.configuration.media_attachments.video_duration_limit;
const isImage = file.type.match(/image.*/);
const isVideo = file.type.match(/video.*/);

@ -4,19 +4,21 @@ import ConfigDB from 'soapbox/utils/config-db';
import { fetchConfig, updateConfig } from './admin';
import type { MRFSimple } from 'soapbox/schemas/pleroma';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Policy } from 'soapbox/utils/config-db';
const simplePolicyMerge = (simplePolicy: Policy, host: string, restrictions: ImmutableMap<string, any>) => {
return simplePolicy.map((hosts, key) => {
const simplePolicyMerge = (simplePolicy: MRFSimple, host: string, restrictions: ImmutableMap<string, any>) => {
const entries = Object.entries(simplePolicy).map(([key, hosts]) => {
const isRestricted = restrictions.get(key);
if (isRestricted) {
return ImmutableSet(hosts).add(host);
return [key, ImmutableSet(hosts).add(host).toJS()];
} else {
return ImmutableSet(hosts).delete(host);
return [key, ImmutableSet(hosts).delete(host).toJS()];
}
});
return Object.fromEntries(entries);
};
const updateMrf = (host: string, restrictions: ImmutableMap<string, any>) =>

@ -6,10 +6,10 @@ import { connectRequestSchema } from 'soapbox/schemas/nostr';
import { jsonSchema } from 'soapbox/schemas/utils';
function useSignerStream() {
const { nostr } = useInstance();
const instance = useInstance();
const relayUrl = nostr.get('relay') as string | undefined;
const pubkey = nostr.get('pubkey') as string | undefined;
const relayUrl = instance.nostr?.relay;
const pubkey = instance.nostr?.pubkey;
useEffect(() => {
let relay: Relay | undefined;

@ -14,7 +14,7 @@ function useTimelineStream(...args: Parameters<typeof connectTimelineStream>) {
const stream = useRef<(() => void) | null>(null);
const accessToken = useAppSelector(getAccessToken);
const streamingUrl = instance.urls.get('streaming_api');
const streamingUrl = instance.urls?.streaming_api;
const connect = () => {
if (enabled && streamingUrl && !stream.current) {

@ -26,7 +26,7 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
const instance = useInstance();
const supportsBirthdays = features.birthdays;
const minAge = instance.pleroma.getIn(['metadata', 'birthday_min_age']) as number;
const minAge = instance.pleroma.metadata.birthday_min_age;
const maxDate = useMemo(() => {
if (!supportsBirthdays) return null;

@ -1,4 +1,3 @@
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
@ -22,11 +21,12 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
const me = useAppSelector((state) => state.me);
const allowUnauthenticated = instance.pleroma.getIn(['metadata', 'translation', 'allow_unauthenticated'], false);
const allowRemote = instance.pleroma.getIn(['metadata', 'translation', 'allow_remote'], true);
const sourceLanguages = instance.pleroma.getIn(['metadata', 'translation', 'source_languages']) as ImmutableList<string>;
const targetLanguages = instance.pleroma.getIn(['metadata', 'translation', 'target_languages']) as ImmutableList<string>;
const {
allow_remote: allowRemote,
allow_unauthenticated: allowUnauthenticated,
source_languages: sourceLanguages,
target_languages: targetLanguages,
} = instance.pleroma.metadata.translation;
const renderTranslate = (me || allowUnauthenticated) && (allowRemote || isLocal(status.account as Account)) && ['public', 'unlisted'].includes(status.visibility) && status.contentHtml.length > 0 && status.language !== null && intl.locale !== status.language;

@ -4,10 +4,9 @@ import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { updateConfig } from 'soapbox/actions/admin';
import { RadioGroup, RadioItem } from 'soapbox/components/radio';
import { useAppDispatch, useInstance } from 'soapbox/hooks';
import { Instance } from 'soapbox/schemas';
import toast from 'soapbox/toast';
import type { Instance } from 'soapbox/types/entities';
type RegistrationMode = 'open' | 'approval' | 'closed';
const messages = defineMessages({

@ -41,11 +41,13 @@ const Dashboard: React.FC = () => {
const v = parseVersion(instance.version);
const userCount = instance.stats.get('user_count');
const statusCount = instance.stats.get('status_count');
const domainCount = instance.stats.get('domain_count');
const {
user_count: userCount,
status_count: statusCount,
domain_count: domainCount,
} = instance.stats;
const mau = instance.pleroma.getIn(['stats', 'mau']) as number | undefined;
const mau = instance.pleroma.stats.mau;
const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : undefined;
if (!account) return null;

@ -1,4 +1,3 @@
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { FormattedMessage } from 'react-intl';
@ -13,9 +12,9 @@ interface IConsumersList {
/** Displays OAuth consumers to log in with. */
const ConsumersList: React.FC<IConsumersList> = () => {
const instance = useInstance();
const providers = ImmutableList<string>(instance.pleroma.get('oauth_consumer_strategies'));
const providers = instance.pleroma.oauth_consumer_strategies;
if (providers.size > 0) {
if (providers.length > 0) {
return (
<Card className='bg-gray-50 p-4 dark:bg-primary-800 sm:rounded-xl'>
<Text size='xs' theme='muted'>

@ -46,11 +46,11 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
const instance = useInstance();
const locale = settings.get('locale');
const needsConfirmation = !!instance.pleroma.getIn(['metadata', 'account_activation_required']);
const needsConfirmation = instance.pleroma.metadata.account_activation_required;
const needsApproval = instance.approval_required;
const supportsEmailList = features.emailList;
const supportsAccountLookup = features.accountLookup;
const birthdayRequired = instance.pleroma.getIn(['metadata', 'birthday_required']);
const birthdayRequired = instance.pleroma.metadata.birthday_required;
const [captchaLoading, setCaptchaLoading] = useState(true);
const [submissionLoading, setSubmissionLoading] = useState(false);

@ -3,8 +3,8 @@ import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { ChatContext } from 'soapbox/contexts/chat-context';
import { buildAccount } from 'soapbox/jest/factory';
import { normalizeChatMessage, normalizeInstance } from 'soapbox/normalizers';
import { buildAccount, buildInstance } from 'soapbox/jest/factory';
import { normalizeChatMessage } from 'soapbox/normalizers';
import { ChatMessage } from 'soapbox/types/entities';
import { __stub } from '../../../../api';
@ -70,7 +70,7 @@ Object.assign(navigator, {
const store = rootState
.set('me', '1')
.set('instance', normalizeInstance({ version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)' }));
.set('instance', buildInstance({ version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)' }));
const renderComponentWithChatContext = () => render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>

@ -76,8 +76,8 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocked_by']));
const isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking']));
const maxCharacterCount = useAppSelector((state) => state.instance.getIn(['configuration', 'chats', 'max_characters']) as number);
const attachmentLimit = useAppSelector(state => state.instance.configuration.getIn(['chats', 'max_media_attachments']) as number);
const maxCharacterCount = useAppSelector((state) => state.instance.configuration.chats.max_characters);
const attachmentLimit = useAppSelector(state => state.instance.configuration.chats.max_media_attachments);
const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState);
const isSuggestionsAvailable = suggestions.list.length > 0;

@ -53,7 +53,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
const dispatch = useAppDispatch();
const { createChatMessage, acceptChat } = useChatActions(chat.id);
const attachmentLimit = useAppSelector(state => state.instance.configuration.getIn(['chats', 'max_media_attachments']) as number);
const attachmentLimit = useAppSelector(state => state.instance.configuration.chats.max_media_attachments);
const [content, setContent] = useState<string>('');
const [attachments, setAttachments] = useState<Attachment[]>([]);

@ -77,7 +77,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
const compose = useCompose(id);
const showSearch = useAppSelector((state) => state.search.submitted && !state.search.hidden);
const maxTootChars = configuration.getIn(['statuses', 'max_characters']) as number;
const maxTootChars = configuration.statuses.max_characters;
const scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size);
const features = useFeatures();

@ -8,7 +8,6 @@ import { useAppDispatch, useCompose, useInstance } from 'soapbox/hooks';
import DurationSelector from './duration-selector';
import type { Map as ImmutableMap } from 'immutable';
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
const messages = defineMessages({
@ -115,13 +114,14 @@ const PollForm: React.FC<IPollForm> = ({ composeId }) => {
const compose = useCompose(composeId);
const pollLimits = configuration.get('polls') as ImmutableMap<string, number>;
const options = compose.poll?.options;
const expiresIn = compose.poll?.expires_in;
const isMultiple = compose.poll?.multiple;
const maxOptions = pollLimits.get('max_options') as number;
const maxOptionChars = pollLimits.get('max_characters_per_option') as number;
const {
max_options: maxOptions,
max_characters_per_option: maxOptionChars,
} = configuration.polls;
const onRemoveOption = (index: number) => dispatch(removePollOption(composeId, index));
const onChangeOption = (index: number, title: string) => dispatch(changePollOption(composeId, index, title));

@ -4,14 +4,12 @@ import { defineMessages, IntlShape, useIntl } from 'react-intl';
import { IconButton } from 'soapbox/components/ui';
import { useInstance } from 'soapbox/hooks';
import type { List as ImmutableList } from 'immutable';
const messages = defineMessages({
upload: { id: 'upload_button.label', defaultMessage: 'Add media attachment' },
});
export const onlyImages = (types: ImmutableList<string>) => {
return Boolean(types && types.every(type => type.startsWith('image/')));
export const onlyImages = (types: string[] | undefined): boolean => {
return types?.every((type) => type.startsWith('image/')) ?? false;
};
export interface IUploadButton {
@ -38,7 +36,7 @@ const UploadButton: React.FC<IUploadButton> = ({
const { configuration } = useInstance();
const fileElement = useRef<HTMLInputElement>(null);
const attachmentTypes = configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>;
const attachmentTypes = configuration.media_attachments.supported_mime_types;
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
if (e.target.files?.length) {
@ -78,7 +76,7 @@ const UploadButton: React.FC<IUploadButton> = ({
ref={fileElement}
type='file'
multiple
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
accept={attachmentTypes?.join(',')}
onChange={handleChange}
disabled={disabled}
className='hidden'

@ -8,7 +8,7 @@ import { useDraggedFiles } from 'soapbox/hooks';
interface IMediaInput {
className?: string
src: string | undefined
accept: string
accept?: string
onChange: (files: FileList | null) => void
disabled?: boolean
}

@ -11,7 +11,7 @@ const messages = defineMessages({
interface IMediaInput {
src: string | undefined
accept: string
accept?: string
onChange: (files: FileList | null) => void
onClear?: () => void
disabled?: boolean

@ -25,7 +25,6 @@ import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
import AvatarPicker from './components/avatar-picker';
import HeaderPicker from './components/header-picker';
import type { List as ImmutableList } from 'immutable';
import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield';
import type { Account } from 'soapbox/schemas';
@ -183,11 +182,12 @@ const EditProfile: React.FC = () => {
const { account } = useOwnAccount();
const features = useFeatures();
const maxFields = instance.pleroma.getIn(['metadata', 'fields_limits', 'max_fields'], 4) as number;
const maxFields = instance.pleroma.metadata.fields_limits.max_fields;
const attachmentTypes = useAppSelector(
state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>,
)?.filter(type => type.startsWith('image/')).toArray().join(',');
state => state.instance.configuration.media_attachments.supported_mime_types)
?.filter(type => type.startsWith('image/'))
.join(',');
const [isLoading, setLoading] = useState(false);
const [data, setData] = useState<AccountCredentials>({});

@ -8,10 +8,8 @@ import { useInstance } from 'soapbox/hooks';
import type { Map as ImmutableMap } from 'immutable';
const hasRestrictions = (remoteInstance: ImmutableMap<string, any>): boolean => {
return remoteInstance
.get('federation')
.deleteAll(['accept', 'reject_deletes', 'report_removal'])
.reduce((acc: boolean, value: boolean) => acc || value, false);
const { accept, reject_deletes, report_removal, ...federation } = remoteInstance.get('federation');
return !!Object.values(federation).reduce((acc, value) => Boolean(acc || value), false);
};
interface IRestriction {
@ -111,7 +109,7 @@ const InstanceRestrictions: React.FC<IInstanceRestrictions> = ({ remoteInstance
if (!instance || !remoteInstance) return null;
const host = remoteInstance.get('host');
const siteTitle = instance.get('title');
const siteTitle = instance.title;
if (remoteInstance.getIn(['federation', 'reject']) === true) {
return (

@ -13,8 +13,6 @@ import HeaderPicker from '../edit-profile/components/header-picker';
import GroupTagsField from './components/group-tags-field';
import type { List as ImmutableList } from 'immutable';
const nonDefaultAvatar = (url: string | undefined) => url && isDefaultAvatar(url) ? undefined : url;
const nonDefaultHeader = (url: string | undefined) => url && isDefaultHeader(url) ? undefined : url;
@ -48,12 +46,12 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { groupId } }) => {
const displayName = useTextField(group?.display_name);
const note = useTextField(group?.note_plain);
const maxName = Number(instance.configuration.getIn(['groups', 'max_characters_name']));
const maxNote = Number(instance.configuration.getIn(['groups', 'max_characters_description']));
const maxName = Number(instance.configuration.groups.max_characters_name);
const maxNote = Number(instance.configuration.groups.max_characters_description);
const attachmentTypes = useAppSelector(
state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>,
)?.filter(type => type.startsWith('image/')).toArray().join(',');
const attachmentTypes = useAppSelector(state => state.instance.configuration.media_attachments.supported_mime_types)
?.filter((type) => type.startsWith('image/'))
.join(',');
async function handleSubmit() {
setIsSubmitting(true);

@ -23,7 +23,7 @@ const Migration = () => {
const dispatch = useAppDispatch();
const instance = useInstance();
const cooldownPeriod = instance.pleroma.getIn(['metadata', 'migration_cooldown_period']) as number | undefined;
const cooldownPeriod = instance.pleroma.metadata.migration_cooldown_period;
const [targetAccount, setTargetAccount] = useState('');
const [password, setPassword] = useState('');

@ -5,8 +5,6 @@ import Icon from 'soapbox/components/icon';
import { HStack, Text } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import type { List as ImmutableList } from 'immutable';
interface IUploadButton {
disabled?: boolean
onSelectFile: (files: FileList) => void
@ -14,7 +12,8 @@ interface IUploadButton {
const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
const fileElement = useRef<HTMLInputElement>(null);
const attachmentTypes = useAppSelector(state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>)?.filter(type => type.startsWith('image/'));
const attachmentTypes = useAppSelector(state => state.instance.configuration.media_attachments.supported_mime_types)
?.filter((type) => type.startsWith('image/'));
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
if (e.target.files?.length) {
@ -40,7 +39,7 @@ const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
<input
ref={fileElement}
type='file'
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
accept={attachmentTypes?.join(',')}
onChange={handleChange}
disabled={disabled}
className='hidden'

@ -1,4 +1,3 @@
import { Map as ImmutableMap } from 'immutable';
import React, { useState, useEffect, useCallback } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@ -31,10 +30,10 @@ const EditFederationModal: React.FC<IEditFederationModal> = ({ host, onClose })
const getRemoteInstance = useCallback(makeGetRemoteInstance(), []);
const remoteInstance = useAppSelector(state => getRemoteInstance(state, host));
const [data, setData] = useState(ImmutableMap<string, any>());
const [data, setData] = useState({} as any);
useEffect(() => {
setData(remoteInstance.get('federation') as any);
setData(remoteInstance.get('federation'));
}, [remoteInstance]);
const handleDataChange = (key: string): React.ChangeEventHandler<HTMLInputElement> => {
@ -69,7 +68,7 @@ const EditFederationModal: React.FC<IEditFederationModal> = ({ host, onClose })
media_nsfw,
media_removal,
reject,
} = data.toJS() as Record<string, boolean>;
} = data;
const fullMediaRemoval = avatar_removal && banner_removal && media_removal;

@ -10,8 +10,6 @@ import { useAppSelector, useDebounce, useInstance } from 'soapbox/hooks';
import { usePreview } from 'soapbox/hooks/forms';
import resizeImage from 'soapbox/utils/resize-image';
import type { List as ImmutableList } from 'immutable';
const messages = defineMessages({
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' },
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
@ -40,9 +38,9 @@ const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
const avatarSrc = usePreview(params.avatar);
const headerSrc = usePreview(params.header);
const attachmentTypes = useAppSelector(
state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>,
)?.filter(type => type.startsWith('image/')).toArray().join(',');
const attachmentTypes = useAppSelector(state => state.instance.configuration.media_attachments.supported_mime_types)
?.filter((type) => type.startsWith('image/'))
.join(',');
const handleTextChange = (property: keyof CreateGroupParams): React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> => {
return (e) => {
@ -107,7 +105,7 @@ const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
placeholder={intl.formatMessage(messages.groupNamePlaceholder)}
value={displayName}
onChange={handleTextChange('display_name')}
maxLength={Number(instance.configuration.getIn(['groups', 'max_characters_name']))}
maxLength={Number(instance.configuration.groups.max_characters_name)}
/>
</FormGroup>
@ -119,7 +117,7 @@ const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
placeholder={intl.formatMessage(messages.groupDescriptionPlaceholder)}
value={note}
onChange={handleTextChange('note')}
maxLength={Number(instance.configuration.getIn(['groups', 'max_characters_description']))}
maxLength={Number(instance.configuration.groups.max_characters_description)}
/>
</FormGroup>

@ -17,6 +17,8 @@ import {
type GroupTag,
type Relationship,
type Status,
Instance,
instanceSchema,
} from 'soapbox/schemas';
import { GroupRoles } from 'soapbox/schemas/group-member';
@ -71,6 +73,10 @@ function buildGroupMember(
}, props));
}
function buildInstance(props: PartialDeep<Instance> = {}) {
return instanceSchema.parse(props);
}
function buildRelationship(props: PartialDeep<Relationship> = {}): Relationship {
return relationshipSchema.parse(Object.assign({
id: uuidv4(),
@ -91,6 +97,7 @@ export {
buildGroupMember,
buildGroupRelationship,
buildGroupTag,
buildInstance,
buildRelationship,
buildStatus,
};

@ -1,5 +1,3 @@
import { Record } from 'immutable';
import { ADMIN_CONFIG_UPDATE_REQUEST } from 'soapbox/actions/admin';
import { rememberInstance } from 'soapbox/actions/instance';
@ -30,8 +28,7 @@ describe('instance reducer', () => {
version: '0.0.0',
};
expect(Record.isRecord(result)).toBe(true);
expect(result.toJS()).toMatchObject(expected);
expect(result).toMatchObject(expected);
});
describe('rememberInstance.fulfilled', () => {
@ -58,7 +55,7 @@ describe('instance reducer', () => {
},
};
expect(result.toJS()).toMatchObject(expected);
expect(result).toMatchObject(expected);
});
it('normalizes Mastodon instance with retained configuration', () => {
@ -92,7 +89,7 @@ describe('instance reducer', () => {
},
};
expect(result.toJS()).toMatchObject(expected);
expect(result).toMatchObject(expected);
});
it('normalizes Mastodon 3.0.0 instance with default configuration', () => {
@ -118,7 +115,7 @@ describe('instance reducer', () => {
},
};
expect(result.toJS()).toMatchObject(expected);
expect(result).toMatchObject(expected);
});
});

@ -1,48 +1,28 @@
import { produce } from 'immer';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import { ADMIN_CONFIG_UPDATE_REQUEST, ADMIN_CONFIG_UPDATE_SUCCESS } from 'soapbox/actions/admin';
import { PLEROMA_PRELOAD_IMPORT } from 'soapbox/actions/preload';
import { normalizeInstance } from 'soapbox/normalizers/instance';
import { type Instance, instanceSchema } from 'soapbox/schemas';
import KVStore from 'soapbox/storage/kv-store';
import { ConfigDB } from 'soapbox/utils/config-db';
import {
rememberInstance,
fetchInstance,
fetchNodeinfo,
} from '../actions/instance';
import type { AnyAction } from 'redux';
const initialState = normalizeInstance(ImmutableMap());
const nodeinfoToInstance = (nodeinfo: ImmutableMap<string, any>) => {
// Match Pleroma's develop branch
return normalizeInstance(ImmutableMap({
pleroma: ImmutableMap({
metadata: ImmutableMap({
account_activation_required: nodeinfo.getIn(['metadata', 'accountActivationRequired']),
features: nodeinfo.getIn(['metadata', 'features']),
federation: nodeinfo.getIn(['metadata', 'federation']),
fields_limits: ImmutableMap({
max_fields: nodeinfo.getIn(['metadata', 'fieldsLimits', 'maxFields']),
}),
}),
}),
}));
};
const importInstance = (_state: typeof initialState, instance: ImmutableMap<string, any>) => {
return normalizeInstance(instance);
};
const initialState: Instance = instanceSchema.parse({});
const importNodeinfo = (state: typeof initialState, nodeinfo: ImmutableMap<string, any>) => {
return nodeinfoToInstance(nodeinfo).mergeDeep(state);
const importInstance = (_state: typeof initialState, instance: unknown) => {
return instanceSchema.parse(instance);
};
const preloadImport = (state: typeof initialState, action: Record<string, any>, path: string) => {
const instance = action.data[path];
return instance ? importInstance(state, ImmutableMap(fromJS(instance))) : state;
return instance ? importInstance(state, instance) : state;
};
const getConfigValue = (instanceConfig: ImmutableMap<string, any>, key: string) => {
@ -59,28 +39,29 @@ const importConfigs = (state: typeof initialState, configs: ImmutableList<any>)
if (!config && !simplePolicy) return state;
return state.withMutations(state => {
return produce(state, (draft) => {
if (config) {
const value = config.get('value', ImmutableList());
const registrationsOpen = getConfigValue(value, ':registrations_open');
const approvalRequired = getConfigValue(value, ':account_approval_required');
const registrationsOpen = getConfigValue(value, ':registrations_open') as boolean | undefined;
const approvalRequired = getConfigValue(value, ':account_approval_required') as boolean | undefined;
state.update('registrations', c => typeof registrationsOpen === 'boolean' ? registrationsOpen : c);
state.update('approval_required', c => typeof approvalRequired === 'boolean' ? approvalRequired : c);
draft.registrations = registrationsOpen ?? draft.registrations;
draft.approval_required = approvalRequired ?? draft.approval_required;
}
if (simplePolicy) {
state.setIn(['pleroma', 'metadata', 'federation', 'mrf_simple'], simplePolicy);
draft.pleroma.metadata.federation.mrf_simple = simplePolicy;
}
});
};
const handleAuthFetch = (state: typeof initialState) => {
// Authenticated fetch is enabled, so make the instance appear censored
return state.mergeWith((o, n) => o || n, {
title: '██████',
description: '████████████',
});
return {
...state,
title: state.title || '██████',
description: state.description || '████████████',
};
};
const getHost = (instance: { uri: string }) => {
@ -116,14 +97,12 @@ export default function instance(state = initialState, action: AnyAction) {
case PLEROMA_PRELOAD_IMPORT:
return preloadImport(state, action, '/api/v1/instance');
case rememberInstance.fulfilled.type:
return importInstance(state, ImmutableMap(fromJS(action.payload)));
return importInstance(state, action.payload);
case fetchInstance.fulfilled.type:
persistInstance(action.payload);
return importInstance(state, ImmutableMap(fromJS(action.payload)));
return importInstance(state, action.payload);
case fetchInstance.rejected.type:
return handleInstanceFetchFail(state, action.error);
case fetchNodeinfo.fulfilled.type:
return importNodeinfo(state, ImmutableMap(fromJS(action.payload)));
case ADMIN_CONFIG_UPDATE_REQUEST:
case ADMIN_CONFIG_UPDATE_SUCCESS:
return importConfigs(state, ImmutableList(fromJS(action.configs)));

@ -1,6 +1,8 @@
import { isBlurhashValid } from 'blurhash';
import { z } from 'zod';
import { mimeSchema } from './utils';
const blurhashSchema = z.string().superRefine((value, ctx) => {
const r = isBlurhashValid(value);
@ -17,7 +19,7 @@ const baseAttachmentSchema = z.object({
description: z.string().catch(''),
id: z.string(),
pleroma: z.object({
mime_type: z.string().regex(/^\w+\/[-+.\w]+$/),
mime_type: mimeSchema,
}).optional().catch(undefined),
preview_url: z.string().url().catch(''),
remote_url: z.string().url().nullable().catch(null),

@ -8,6 +8,7 @@ export { groupSchema, type Group } from './group';
export { groupMemberSchema, type GroupMember } from './group-member';
export { groupRelationshipSchema, type GroupRelationship } from './group-relationship';
export { groupTagSchema, type GroupTag } from './group-tag';
export { instanceSchema, type Instance } from './instance';
export { mentionSchema, type Mention } from './mention';
export { notificationSchema, type Notification } from './notification';
export { patronUserSchema, type PatronUser } from './patron';

@ -0,0 +1,111 @@
/* eslint sort-keys: "error" */
import z from 'zod';
import { accountSchema } from './account';
import { mrfSimpleSchema } from './pleroma';
import { coerceObject, mimeSchema } from './utils';
const configurationSchema = coerceObject({
chats: coerceObject({
max_characters: z.number().catch(5000),
max_media_attachments: z.number().catch(1),
}),
groups: coerceObject({
max_characters_description: z.number().catch(160),
max_characters_name: z.number().catch(50),
}),
media_attachments: coerceObject({
image_matrix_limit: z.number().optional().catch(undefined),
image_size_limit: z.number().optional().catch(undefined),
supported_mime_types: mimeSchema.array().optional().catch(undefined),
video_duration_limit: z.number().optional().catch(undefined),
video_frame_rate_limit: z.number().optional().catch(undefined),
video_matrix_limit: z.number().optional().catch(undefined),
video_size_limit: z.number().optional().catch(undefined),
}),
polls: coerceObject({
max_characters_per_option: z.number().catch(25),
max_expiration: z.number().catch(2629746),
max_options: z.number().catch(4),
min_expiration: z.number().catch(300),
}),
statuses: coerceObject({
max_characters: z.number().catch(500),
max_media_attachments: z.number().catch(4),
}),
});
const nostrSchema = coerceObject({
pubkey: z.string(),
relay: z.string().url(),
});
const pleromaSchema = coerceObject({
metadata: coerceObject({
account_activation_required: z.boolean().catch(false),
birthday_min_age: z.number().catch(0),
birthday_required: z.boolean().catch(false),
features: z.string().array().catch([]),
federation: coerceObject({
enabled: z.boolean().catch(true), // Assume true unless explicitly false
mrf_policies: z.string().array().optional().catch(undefined),
mrf_simple: mrfSimpleSchema,
}),
fields_limits: z.any(),
migration_cooldown_period: z.number().optional().catch(undefined),
translation: coerceObject({
allow_remote: z.boolean().catch(true),
allow_unauthenticated: z.boolean().catch(false),
source_languages: z.string().array().optional().catch(undefined),
target_languages: z.string().array().optional().catch(undefined),
}),
}),
oauth_consumer_strategies: z.string().array().catch([]),
stats: coerceObject({
mau: z.number().optional().catch(undefined),
}),
vapid_public_key: z.string().catch(''),
});
const statsSchema = coerceObject({
domain_count: z.number().catch(0),
status_count: z.number().catch(0),
user_count: z.number().catch(0),
});
const urlsSchema = coerceObject({
streaming_api: z.string().url().optional().catch(undefined),
});
const usageSchema = coerceObject({
users: coerceObject({
active_month: z.number().catch(0),
}),
});
const instanceSchema = coerceObject({
approval_required: z.boolean().catch(false),
configuration: configurationSchema,
contact_account: accountSchema.optional().catch(undefined),
description: z.string().catch(''),
description_limit: z.number().catch(1500),
email: z.string().email().catch(''),
feature_quote: z.boolean().catch(false),
fedibird_capabilities: z.array(z.string()).catch([]),
languages: z.string().array().catch([]),
nostr: nostrSchema.optional().catch(undefined),
pleroma: pleromaSchema,
registrations: z.boolean().catch(false),
rules: z.any(),
short_description: z.string().catch(''),
stats: statsSchema,
thumbnail: z.string().catch(''),
title: z.string().catch(''),
urls: urlsSchema,
usage: usageSchema,
version: z.string().catch(''),
});
type Instance = z.infer<typeof instanceSchema>;
export { instanceSchema, Instance };

@ -0,0 +1,20 @@
import { z } from 'zod';
import { coerceObject } from './utils';
const mrfSimpleSchema = coerceObject({
accept: z.string().array().catch([]),
avatar_removal: z.string().array().catch([]),
banner_removal: z.string().array().catch([]),
federated_timeline_removal: z.string().array().catch([]),
followers_only: z.string().array().catch([]),
media_nsfw: z.string().array().catch([]),
media_removal: z.string().array().catch([]),
reject: z.string().array().catch([]),
reject_deletes: z.string().array().catch([]),
report_removal: z.string().array().catch([]),
});
type MRFSimple = z.infer<typeof mrfSimpleSchema>;
export { mrfSimpleSchema, type MRFSimple };

@ -39,4 +39,12 @@ const jsonSchema = z.string().transform((value, ctx) => {
}
});
export { filteredArray, makeCustomEmojiMap, emojiSchema, contentSchema, dateSchema, jsonSchema };
/** MIME schema, eg `image/png`. */
const mimeSchema = z.string().regex(/^\w+\/[-+.\w]+$/);
/** zod schema to force the value into an object, if it isn't already. */
function coerceObject<T extends z.ZodRawShape>(shape: T) {
return z.object({}).passthrough().catch({}).pipe(z.object(shape));
}
export { filteredArray, makeCustomEmojiMap, emojiSchema, contentSchema, dateSchema, jsonSchema, mimeSchema, coerceObject };

@ -8,6 +8,7 @@ import { createSelector } from 'reselect';
import { getSettings } from 'soapbox/actions/settings';
import { Entities } from 'soapbox/entity-store/entities';
import { type MRFSimple } from 'soapbox/schemas/pleroma';
import { getDomain } from 'soapbox/utils/accounts';
import { validId } from 'soapbox/utils/auth';
import ConfigDB from 'soapbox/utils/config-db';
@ -283,9 +284,12 @@ export const makeGetOtherAccounts = () => {
const getSimplePolicy = createSelector([
(state: RootState) => state.admin.configs,
(state: RootState) => state.instance.pleroma.getIn(['metadata', 'federation', 'mrf_simple'], ImmutableMap()) as ImmutableMap<string, any>,
], (configs, instancePolicy: ImmutableMap<string, any>) => {
return instancePolicy.merge(ConfigDB.toSimplePolicy(configs));
(state: RootState) => state.instance.pleroma.metadata.federation.mrf_simple,
], (configs, instancePolicy) => {
return {
...instancePolicy,
...ConfigDB.toSimplePolicy(configs),
};
});
const getRemoteInstanceFavicon = (state: RootState, host: string) => {
@ -294,15 +298,24 @@ const getRemoteInstanceFavicon = (state: RootState, host: string) => {
return account?.pleroma?.favicon;
};
const getRemoteInstanceFederation = (state: RootState, host: string) => (
getSimplePolicy(state)
.map(hosts => hosts.includes(host))
);
type HostFederation = {
[key in keyof MRFSimple]: boolean;
};
const getRemoteInstanceFederation = (state: RootState, host: string): HostFederation => {
const simplePolicy = getSimplePolicy(state);
return Object.fromEntries(
Object.entries(simplePolicy).map(([key, hosts]) => [key, hosts.includes(host)]),
) as HostFederation;
};
export const makeGetHosts = () => {
return createSelector([getSimplePolicy], (simplePolicy) => {
return simplePolicy
.deleteAll(['accept', 'reject_deletes', 'report_removal'])
const { accept, reject_deletes, report_removal, ...rest } = simplePolicy;
return Object.values(rest)
.reduce((acc, hosts) => acc.union(hosts), ImmutableOrderedSet())
.sort();
});

@ -20,7 +20,7 @@ export function connectStream(
callbacks: (dispatch: AppDispatch, getState: () => RootState) => ConnectStreamCallbacks,
) {
return (dispatch: AppDispatch, getState: () => RootState) => {
const streamingAPIBaseURL = getState().instance.urls.get('streaming_api');
const streamingAPIBaseURL = getState().instance.urls.streaming_api;
const accessToken = getAccessToken(getState());
const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);

@ -1,4 +1,4 @@
import { InstanceRecord } from 'soapbox/normalizers';
import { buildInstance } from 'soapbox/jest/factory';
import {
parseVersion,
@ -77,7 +77,7 @@ describe('parseVersion', () => {
describe('getFeatures', () => {
describe('emojiReacts', () => {
it('is true for Pleroma 2.0+', () => {
const instance = InstanceRecord({
const instance = buildInstance({
version: '2.7.2 (compatible; Pleroma 2.0.5-6-ga36eb5ea-plerasstodon+dev)',
});
const features = getFeatures(instance);
@ -85,7 +85,7 @@ describe('getFeatures', () => {
});
it('is false for Pleroma < 2.0', () => {
const instance = InstanceRecord({
const instance = buildInstance({
version: '2.7.2 (compatible; Pleroma 1.1.50-42-g3d9ac6ae-develop)',
});
const features = getFeatures(instance);
@ -93,7 +93,7 @@ describe('getFeatures', () => {
});
it('is false for Mastodon', () => {
const instance = InstanceRecord({ version: '3.1.4' });
const instance = buildInstance({ version: '3.1.4' });
const features = getFeatures(instance);
expect(features.emojiReacts).toBe(false);
});
@ -101,19 +101,19 @@ describe('getFeatures', () => {
describe('suggestions', () => {
it('is true for Mastodon 2.4.3+', () => {
const instance = InstanceRecord({ version: '2.4.3' });
const instance = buildInstance({ version: '2.4.3' });
const features = getFeatures(instance);
expect(features.suggestions).toBe(true);
});
it('is false for Mastodon < 2.4.3', () => {
const instance = InstanceRecord({ version: '2.4.2' });
const instance = buildInstance({ version: '2.4.2' });
const features = getFeatures(instance);
expect(features.suggestions).toBe(false);
});
it('is false for Pleroma', () => {
const instance = InstanceRecord({
const instance = buildInstance({
version: '2.7.2 (compatible; Pleroma 1.1.50-42-g3d9ac6ae-develop)',
});
const features = getFeatures(instance);
@ -123,19 +123,19 @@ describe('getFeatures', () => {
describe('trends', () => {
it('is true for Mastodon 3.0.0+', () => {
const instance = InstanceRecord({ version: '3.0.0' });
const instance = buildInstance({ version: '3.0.0' });
const features = getFeatures(instance);
expect(features.trends).toBe(true);
});
it('is false for Mastodon < 3.0.0', () => {
const instance = InstanceRecord({ version: '2.4.3' });
const instance = buildInstance({ version: '2.4.3' });
const features = getFeatures(instance);
expect(features.trends).toBe(false);
});
it('is false for Pleroma', () => {
const instance = InstanceRecord({
const instance = buildInstance({
version: '2.7.2 (compatible; Pleroma 1.1.50-42-g3d9ac6ae-develop)',
});
const features = getFeatures(instance);
@ -145,13 +145,13 @@ describe('getFeatures', () => {
describe('focalPoint', () => {
it('is true for Mastodon 2.3.0+', () => {
const instance = InstanceRecord({ version: '2.3.0' });
const instance = buildInstance({ version: '2.3.0' });
const features = getFeatures(instance);
expect(features.focalPoint).toBe(true);
});
it('is false for Pleroma', () => {
const instance = InstanceRecord({
const instance = buildInstance({
version: '2.7.2 (compatible; Pleroma 1.1.50-42-g3d9ac6ae-develop)',
});
const features = getFeatures(instance);

@ -64,6 +64,6 @@ export const getAuthUserUrl = (state: RootState) => {
/** Get the VAPID public key. */
export const getVapidKey = (state: RootState) =>
(state.auth.app.vapid_key || state.instance.pleroma.get('vapid_public_key')) as string;
state.auth.app.vapid_key || state.instance.pleroma.vapid_public_key;
export const getMeUrl = (state: RootState) => selectOwnAccount(state)?.url;

@ -6,6 +6,8 @@ import {
} from 'immutable';
import trimStart from 'lodash/trimStart';
import { type MRFSimple, mrfSimpleSchema } from 'soapbox/schemas/pleroma';
export type Config = ImmutableMap<string, any>;
export type Policy = ImmutableMap<string, any>;
@ -19,7 +21,7 @@ const find = (
);
};
const toSimplePolicy = (configs: ImmutableList<Config>): Policy => {
const toSimplePolicy = (configs: ImmutableList<Config>): MRFSimple => {
const config = find(configs, ':pleroma', ':mrf_simple');
const reducer = (acc: ImmutableMap<string, any>, curr: ImmutableMap<string, any>) => {
@ -30,9 +32,10 @@ const toSimplePolicy = (configs: ImmutableList<Config>): Policy => {
if (config?.get) {
const value = config.get('value', ImmutableList());
return value.reduce(reducer, ImmutableMap());
const result = value.reduce(reducer, ImmutableMap());
return mrfSimpleSchema.parse(result.toJS());
} else {
return ImmutableMap();
return mrfSimpleSchema.parse({});
}
};

@ -1,5 +1,4 @@
/* eslint sort-keys: "error" */
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { createSelector } from 'reselect';
import semverCoerce from 'semver/functions/coerce';
import gte from 'semver/functions/gte';
@ -7,8 +6,7 @@ import lt from 'semver/functions/lt';
import semverParse from 'semver/functions/parse';
import { custom } from 'soapbox/custom';
import type { Instance } from 'soapbox/types/entities';
import { type Instance } from 'soapbox/schemas';
/** Import custom overrides, if exists */
const overrides = custom('features');
@ -100,8 +98,7 @@ export const UNRELEASED = 'unreleased';
/** Parse features for the given instance */
const getInstanceFeatures = (instance: Instance) => {
const v = parseVersion(instance.version);
const features = instance.pleroma.getIn(['metadata', 'features'], ImmutableList()) as ImmutableList<string>;
const federation = instance.pleroma.getIn(['metadata', 'federation'], ImmutableMap()) as ImmutableMap<string, any>;
const { features, federation } = instance.pleroma.metadata;
return {
/**
@ -457,7 +454,7 @@ const getInstanceFeatures = (instance: Instance) => {
]),
/** Whether the instance federates. */
federating: federation.get('enabled', true) === true, // Assume true unless explicitly false
federating: federation.enabled,
/**
* Can edit and manage timeline filters (aka "muted words").

@ -3,8 +3,8 @@ import { createSelector } from 'reselect';
import { parseVersion, PLEROMA, MITRA } from './features';
import type { Instance } from 'soapbox/schemas';
import type { RootState } from 'soapbox/store';
import type { Instance } from 'soapbox/types/entities';
/** For solving bugs between API implementations. */
export const getQuirks = createSelector([

@ -1,8 +1,8 @@
import { PLEROMA, parseVersion } from './features';
import type { Instance } from 'soapbox/schemas';
import type { RootState } from 'soapbox/store';
import type { Instance } from 'soapbox/types/entities';
/**
* Get the OAuth scopes to use for login & signup.
@ -20,9 +20,7 @@ const getInstanceScopes = (instance: Instance) => {
};
/** Convenience function to get scopes from instance in store. */
const getScopes = (state: RootState) => {
return getInstanceScopes(state.instance);
};
const getScopes = (state: RootState) => getInstanceScopes(state.instance);
export {
getInstanceScopes,

@ -18,7 +18,7 @@ export const displayFqn = (state: RootState): boolean => {
/** Whether the instance exposes instance blocks through the API. */
export const federationRestrictionsDisclosed = (state: RootState): boolean => {
return state.instance.pleroma.hasIn(['metadata', 'federation', 'mrf_policies']);
return !!state.instance.pleroma.metadata.federation.mrf_policies;
};
/**

Loading…
Cancel
Save