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

@ -2,8 +2,9 @@ import { List as ImmutableList } from 'immutable';
import { fetchAnnouncements, dismissAnnouncement, addReaction, removeReaction } from 'soapbox/actions/announcements'; import { fetchAnnouncements, dismissAnnouncement, addReaction, removeReaction } from 'soapbox/actions/announcements';
import { __stub } from 'soapbox/api'; import { __stub } from 'soapbox/api';
import { buildInstance } from 'soapbox/jest/factory';
import { mockStore, rootState } from 'soapbox/jest/test-helpers'; 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'; import type { APIEntity } from 'soapbox/types/entities';
@ -13,7 +14,7 @@ describe('fetchAnnouncements()', () => {
describe('with a successful API request', () => { describe('with a successful API request', () => {
it('should fetch announcements from the API', async() => { it('should fetch announcements from the API', async() => {
const state = rootState const state = rootState
.set('instance', normalizeInstance({ version: '3.5.3' })); .set('instance', buildInstance({ version: '3.5.3' }));
const store = mockStore(state); const store = mockStore(state);
__stub((mock) => { __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 { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { InstanceRecord } from 'soapbox/normalizers';
import { ReducerCompose } from 'soapbox/reducers/compose'; import { ReducerCompose } from 'soapbox/reducers/compose';
import { uploadCompose, submitCompose } from '../compose'; import { uploadCompose, submitCompose } from '../compose';
@ -14,15 +14,15 @@ describe('uploadCompose()', () => {
let files: FileList, store: ReturnType<typeof mockStore>; let files: FileList, store: ReturnType<typeof mockStore>;
beforeEach(() => { beforeEach(() => {
const instance = InstanceRecord({ const instance = buildInstance({
configuration: ImmutableMap({ configuration: {
statuses: ImmutableMap({ statuses: {
max_media_attachments: 4, max_media_attachments: 4,
}), },
media_attachments: ImmutableMap({ media_attachments: {
image_size_limit: 10, image_size_limit: 10,
}), },
}), },
}); });
const state = rootState const state = rootState
@ -60,15 +60,15 @@ describe('uploadCompose()', () => {
let files: FileList, store: ReturnType<typeof mockStore>; let files: FileList, store: ReturnType<typeof mockStore>;
beforeEach(() => { beforeEach(() => {
const instance = InstanceRecord({ const instance = buildInstance({
configuration: ImmutableMap({ configuration: {
statuses: ImmutableMap({ statuses: {
max_media_attachments: 4, max_media_attachments: 4,
}), },
media_attachments: ImmutableMap({ media_attachments: {
video_size_limit: 10, video_size_limit: 10,
}), },
}), },
}); });
const state = rootState const state = rootState

@ -393,7 +393,7 @@ const submitComposeFail = (composeId: string, error: AxiosError) => ({
const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return; 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 media = getState().compose.get(composeId)?.media_attachments;
const progress = new Array(files.length).fill(0); const progress = new Array(files.length).fill(0);

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

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

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

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

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

@ -26,7 +26,7 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
const instance = useInstance(); const instance = useInstance();
const supportsBirthdays = features.birthdays; 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(() => { const maxDate = useMemo(() => {
if (!supportsBirthdays) return null; if (!supportsBirthdays) return null;

@ -1,4 +1,3 @@
import { List as ImmutableList } from 'immutable';
import React from 'react'; import React from 'react';
import { FormattedMessage, useIntl } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
@ -22,11 +21,12 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
const me = useAppSelector((state) => state.me); const me = useAppSelector((state) => state.me);
const allowUnauthenticated = instance.pleroma.getIn(['metadata', 'translation', 'allow_unauthenticated'], false); const {
const allowRemote = instance.pleroma.getIn(['metadata', 'translation', 'allow_remote'], true); allow_remote: allowRemote,
allow_unauthenticated: allowUnauthenticated,
const sourceLanguages = instance.pleroma.getIn(['metadata', 'translation', 'source_languages']) as ImmutableList<string>; source_languages: sourceLanguages,
const targetLanguages = instance.pleroma.getIn(['metadata', 'translation', 'target_languages']) as ImmutableList<string>; 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; 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 { updateConfig } from 'soapbox/actions/admin';
import { RadioGroup, RadioItem } from 'soapbox/components/radio'; import { RadioGroup, RadioItem } from 'soapbox/components/radio';
import { useAppDispatch, useInstance } from 'soapbox/hooks'; import { useAppDispatch, useInstance } from 'soapbox/hooks';
import { Instance } from 'soapbox/schemas';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import type { Instance } from 'soapbox/types/entities';
type RegistrationMode = 'open' | 'approval' | 'closed'; type RegistrationMode = 'open' | 'approval' | 'closed';
const messages = defineMessages({ const messages = defineMessages({

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

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

@ -46,11 +46,11 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
const instance = useInstance(); const instance = useInstance();
const locale = settings.get('locale'); 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 needsApproval = instance.approval_required;
const supportsEmailList = features.emailList; const supportsEmailList = features.emailList;
const supportsAccountLookup = features.accountLookup; 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 [captchaLoading, setCaptchaLoading] = useState(true);
const [submissionLoading, setSubmissionLoading] = useState(false); const [submissionLoading, setSubmissionLoading] = useState(false);

@ -3,8 +3,8 @@ import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso'; import { VirtuosoMockContext } from 'react-virtuoso';
import { ChatContext } from 'soapbox/contexts/chat-context'; import { ChatContext } from 'soapbox/contexts/chat-context';
import { buildAccount } from 'soapbox/jest/factory'; import { buildAccount, buildInstance } from 'soapbox/jest/factory';
import { normalizeChatMessage, normalizeInstance } from 'soapbox/normalizers'; import { normalizeChatMessage } from 'soapbox/normalizers';
import { ChatMessage } from 'soapbox/types/entities'; import { ChatMessage } from 'soapbox/types/entities';
import { __stub } from '../../../../api'; import { __stub } from '../../../../api';
@ -70,7 +70,7 @@ Object.assign(navigator, {
const store = rootState const store = rootState
.set('me', '1') .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( const renderComponentWithChatContext = () => render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}> <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 isBlocked = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocked_by']));
const isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking'])); 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 maxCharacterCount = useAppSelector((state) => state.instance.configuration.chats.max_characters);
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 [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState); const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState);
const isSuggestionsAvailable = suggestions.list.length > 0; const isSuggestionsAvailable = suggestions.list.length > 0;

@ -53,7 +53,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { createChatMessage, acceptChat } = useChatActions(chat.id); 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 [content, setContent] = useState<string>('');
const [attachments, setAttachments] = useState<Attachment[]>([]); const [attachments, setAttachments] = useState<Attachment[]>([]);

@ -77,7 +77,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
const compose = useCompose(id); const compose = useCompose(id);
const showSearch = useAppSelector((state) => state.search.submitted && !state.search.hidden); 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 scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size);
const features = useFeatures(); const features = useFeatures();

@ -8,7 +8,6 @@ import { useAppDispatch, useCompose, useInstance } from 'soapbox/hooks';
import DurationSelector from './duration-selector'; import DurationSelector from './duration-selector';
import type { Map as ImmutableMap } from 'immutable';
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input'; import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
const messages = defineMessages({ const messages = defineMessages({
@ -115,13 +114,14 @@ const PollForm: React.FC<IPollForm> = ({ composeId }) => {
const compose = useCompose(composeId); const compose = useCompose(composeId);
const pollLimits = configuration.get('polls') as ImmutableMap<string, number>;
const options = compose.poll?.options; const options = compose.poll?.options;
const expiresIn = compose.poll?.expires_in; const expiresIn = compose.poll?.expires_in;
const isMultiple = compose.poll?.multiple; const isMultiple = compose.poll?.multiple;
const maxOptions = pollLimits.get('max_options') as number; const {
const maxOptionChars = pollLimits.get('max_characters_per_option') as number; max_options: maxOptions,
max_characters_per_option: maxOptionChars,
} = configuration.polls;
const onRemoveOption = (index: number) => dispatch(removePollOption(composeId, index)); const onRemoveOption = (index: number) => dispatch(removePollOption(composeId, index));
const onChangeOption = (index: number, title: string) => dispatch(changePollOption(composeId, index, title)); 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 { IconButton } from 'soapbox/components/ui';
import { useInstance } from 'soapbox/hooks'; import { useInstance } from 'soapbox/hooks';
import type { List as ImmutableList } from 'immutable';
const messages = defineMessages({ const messages = defineMessages({
upload: { id: 'upload_button.label', defaultMessage: 'Add media attachment' }, upload: { id: 'upload_button.label', defaultMessage: 'Add media attachment' },
}); });
export const onlyImages = (types: ImmutableList<string>) => { export const onlyImages = (types: string[] | undefined): boolean => {
return Boolean(types && types.every(type => type.startsWith('image/'))); return types?.every((type) => type.startsWith('image/')) ?? false;
}; };
export interface IUploadButton { export interface IUploadButton {
@ -38,7 +36,7 @@ const UploadButton: React.FC<IUploadButton> = ({
const { configuration } = useInstance(); const { configuration } = useInstance();
const fileElement = useRef<HTMLInputElement>(null); 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) => { const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
if (e.target.files?.length) { if (e.target.files?.length) {
@ -78,7 +76,7 @@ const UploadButton: React.FC<IUploadButton> = ({
ref={fileElement} ref={fileElement}
type='file' type='file'
multiple multiple
accept={attachmentTypes && attachmentTypes.toArray().join(',')} accept={attachmentTypes?.join(',')}
onChange={handleChange} onChange={handleChange}
disabled={disabled} disabled={disabled}
className='hidden' className='hidden'

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

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

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

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

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

@ -23,7 +23,7 @@ const Migration = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const instance = useInstance(); 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 [targetAccount, setTargetAccount] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');

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

@ -1,4 +1,3 @@
import { Map as ImmutableMap } from 'immutable';
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@ -31,10 +30,10 @@ const EditFederationModal: React.FC<IEditFederationModal> = ({ host, onClose })
const getRemoteInstance = useCallback(makeGetRemoteInstance(), []); const getRemoteInstance = useCallback(makeGetRemoteInstance(), []);
const remoteInstance = useAppSelector(state => getRemoteInstance(state, host)); const remoteInstance = useAppSelector(state => getRemoteInstance(state, host));
const [data, setData] = useState(ImmutableMap<string, any>()); const [data, setData] = useState({} as any);
useEffect(() => { useEffect(() => {
setData(remoteInstance.get('federation') as any); setData(remoteInstance.get('federation'));
}, [remoteInstance]); }, [remoteInstance]);
const handleDataChange = (key: string): React.ChangeEventHandler<HTMLInputElement> => { const handleDataChange = (key: string): React.ChangeEventHandler<HTMLInputElement> => {
@ -69,7 +68,7 @@ const EditFederationModal: React.FC<IEditFederationModal> = ({ host, onClose })
media_nsfw, media_nsfw,
media_removal, media_removal,
reject, reject,
} = data.toJS() as Record<string, boolean>; } = data;
const fullMediaRemoval = avatar_removal && banner_removal && media_removal; 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 { usePreview } from 'soapbox/hooks/forms';
import resizeImage from 'soapbox/utils/resize-image'; import resizeImage from 'soapbox/utils/resize-image';
import type { List as ImmutableList } from 'immutable';
const messages = defineMessages({ const messages = defineMessages({
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' }, groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' },
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' }, 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 avatarSrc = usePreview(params.avatar);
const headerSrc = usePreview(params.header); const headerSrc = usePreview(params.header);
const attachmentTypes = useAppSelector( const attachmentTypes = useAppSelector(state => state.instance.configuration.media_attachments.supported_mime_types)
state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>, ?.filter((type) => type.startsWith('image/'))
)?.filter(type => type.startsWith('image/')).toArray().join(','); .join(',');
const handleTextChange = (property: keyof CreateGroupParams): React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> => { const handleTextChange = (property: keyof CreateGroupParams): React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> => {
return (e) => { return (e) => {
@ -107,7 +105,7 @@ const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
placeholder={intl.formatMessage(messages.groupNamePlaceholder)} placeholder={intl.formatMessage(messages.groupNamePlaceholder)}
value={displayName} value={displayName}
onChange={handleTextChange('display_name')} onChange={handleTextChange('display_name')}
maxLength={Number(instance.configuration.getIn(['groups', 'max_characters_name']))} maxLength={Number(instance.configuration.groups.max_characters_name)}
/> />
</FormGroup> </FormGroup>
@ -119,7 +117,7 @@ const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
placeholder={intl.formatMessage(messages.groupDescriptionPlaceholder)} placeholder={intl.formatMessage(messages.groupDescriptionPlaceholder)}
value={note} value={note}
onChange={handleTextChange('note')} onChange={handleTextChange('note')}
maxLength={Number(instance.configuration.getIn(['groups', 'max_characters_description']))} maxLength={Number(instance.configuration.groups.max_characters_description)}
/> />
</FormGroup> </FormGroup>

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

@ -1,5 +1,3 @@
import { Record } from 'immutable';
import { ADMIN_CONFIG_UPDATE_REQUEST } from 'soapbox/actions/admin'; import { ADMIN_CONFIG_UPDATE_REQUEST } from 'soapbox/actions/admin';
import { rememberInstance } from 'soapbox/actions/instance'; import { rememberInstance } from 'soapbox/actions/instance';
@ -30,8 +28,7 @@ describe('instance reducer', () => {
version: '0.0.0', version: '0.0.0',
}; };
expect(Record.isRecord(result)).toBe(true); expect(result).toMatchObject(expected);
expect(result.toJS()).toMatchObject(expected);
}); });
describe('rememberInstance.fulfilled', () => { 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', () => { 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', () => { 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 { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import { ADMIN_CONFIG_UPDATE_REQUEST, ADMIN_CONFIG_UPDATE_SUCCESS } from 'soapbox/actions/admin'; import { ADMIN_CONFIG_UPDATE_REQUEST, ADMIN_CONFIG_UPDATE_SUCCESS } from 'soapbox/actions/admin';
import { PLEROMA_PRELOAD_IMPORT } from 'soapbox/actions/preload'; 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 KVStore from 'soapbox/storage/kv-store';
import { ConfigDB } from 'soapbox/utils/config-db'; import { ConfigDB } from 'soapbox/utils/config-db';
import { import {
rememberInstance, rememberInstance,
fetchInstance, fetchInstance,
fetchNodeinfo,
} from '../actions/instance'; } from '../actions/instance';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
const initialState = normalizeInstance(ImmutableMap()); const initialState: Instance = instanceSchema.parse({});
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 importNodeinfo = (state: typeof initialState, nodeinfo: ImmutableMap<string, any>) => { const importInstance = (_state: typeof initialState, instance: unknown) => {
return nodeinfoToInstance(nodeinfo).mergeDeep(state); return instanceSchema.parse(instance);
}; };
const preloadImport = (state: typeof initialState, action: Record<string, any>, path: string) => { const preloadImport = (state: typeof initialState, action: Record<string, any>, path: string) => {
const instance = action.data[path]; 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) => { 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; if (!config && !simplePolicy) return state;
return state.withMutations(state => { return produce(state, (draft) => {
if (config) { if (config) {
const value = config.get('value', ImmutableList()); const value = config.get('value', ImmutableList());
const registrationsOpen = getConfigValue(value, ':registrations_open'); const registrationsOpen = getConfigValue(value, ':registrations_open') as boolean | undefined;
const approvalRequired = getConfigValue(value, ':account_approval_required'); const approvalRequired = getConfigValue(value, ':account_approval_required') as boolean | undefined;
state.update('registrations', c => typeof registrationsOpen === 'boolean' ? registrationsOpen : c); draft.registrations = registrationsOpen ?? draft.registrations;
state.update('approval_required', c => typeof approvalRequired === 'boolean' ? approvalRequired : c); draft.approval_required = approvalRequired ?? draft.approval_required;
} }
if (simplePolicy) { if (simplePolicy) {
state.setIn(['pleroma', 'metadata', 'federation', 'mrf_simple'], simplePolicy); draft.pleroma.metadata.federation.mrf_simple = simplePolicy;
} }
}); });
}; };
const handleAuthFetch = (state: typeof initialState) => { const handleAuthFetch = (state: typeof initialState) => {
// Authenticated fetch is enabled, so make the instance appear censored // Authenticated fetch is enabled, so make the instance appear censored
return state.mergeWith((o, n) => o || n, { return {
title: '██████', ...state,
description: '████████████', title: state.title || '██████',
}); description: state.description || '████████████',
};
}; };
const getHost = (instance: { uri: string }) => { const getHost = (instance: { uri: string }) => {
@ -116,14 +97,12 @@ export default function instance(state = initialState, action: AnyAction) {
case PLEROMA_PRELOAD_IMPORT: case PLEROMA_PRELOAD_IMPORT:
return preloadImport(state, action, '/api/v1/instance'); return preloadImport(state, action, '/api/v1/instance');
case rememberInstance.fulfilled.type: case rememberInstance.fulfilled.type:
return importInstance(state, ImmutableMap(fromJS(action.payload))); return importInstance(state, action.payload);
case fetchInstance.fulfilled.type: case fetchInstance.fulfilled.type:
persistInstance(action.payload); persistInstance(action.payload);
return importInstance(state, ImmutableMap(fromJS(action.payload))); return importInstance(state, action.payload);
case fetchInstance.rejected.type: case fetchInstance.rejected.type:
return handleInstanceFetchFail(state, action.error); 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_REQUEST:
case ADMIN_CONFIG_UPDATE_SUCCESS: case ADMIN_CONFIG_UPDATE_SUCCESS:
return importConfigs(state, ImmutableList(fromJS(action.configs))); return importConfigs(state, ImmutableList(fromJS(action.configs)));

@ -1,6 +1,8 @@
import { isBlurhashValid } from 'blurhash'; import { isBlurhashValid } from 'blurhash';
import { z } from 'zod'; import { z } from 'zod';
import { mimeSchema } from './utils';
const blurhashSchema = z.string().superRefine((value, ctx) => { const blurhashSchema = z.string().superRefine((value, ctx) => {
const r = isBlurhashValid(value); const r = isBlurhashValid(value);
@ -17,7 +19,7 @@ const baseAttachmentSchema = z.object({
description: z.string().catch(''), description: z.string().catch(''),
id: z.string(), id: z.string(),
pleroma: z.object({ pleroma: z.object({
mime_type: z.string().regex(/^\w+\/[-+.\w]+$/), mime_type: mimeSchema,
}).optional().catch(undefined), }).optional().catch(undefined),
preview_url: z.string().url().catch(''), preview_url: z.string().url().catch(''),
remote_url: z.string().url().nullable().catch(null), 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 { groupMemberSchema, type GroupMember } from './group-member';
export { groupRelationshipSchema, type GroupRelationship } from './group-relationship'; export { groupRelationshipSchema, type GroupRelationship } from './group-relationship';
export { groupTagSchema, type GroupTag } from './group-tag'; export { groupTagSchema, type GroupTag } from './group-tag';
export { instanceSchema, type Instance } from './instance';
export { mentionSchema, type Mention } from './mention'; export { mentionSchema, type Mention } from './mention';
export { notificationSchema, type Notification } from './notification'; export { notificationSchema, type Notification } from './notification';
export { patronUserSchema, type PatronUser } from './patron'; 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 { getSettings } from 'soapbox/actions/settings';
import { Entities } from 'soapbox/entity-store/entities'; import { Entities } from 'soapbox/entity-store/entities';
import { type MRFSimple } from 'soapbox/schemas/pleroma';
import { getDomain } from 'soapbox/utils/accounts'; import { getDomain } from 'soapbox/utils/accounts';
import { validId } from 'soapbox/utils/auth'; import { validId } from 'soapbox/utils/auth';
import ConfigDB from 'soapbox/utils/config-db'; import ConfigDB from 'soapbox/utils/config-db';
@ -283,9 +284,12 @@ export const makeGetOtherAccounts = () => {
const getSimplePolicy = createSelector([ const getSimplePolicy = createSelector([
(state: RootState) => state.admin.configs, (state: RootState) => state.admin.configs,
(state: RootState) => state.instance.pleroma.getIn(['metadata', 'federation', 'mrf_simple'], ImmutableMap()) as ImmutableMap<string, any>, (state: RootState) => state.instance.pleroma.metadata.federation.mrf_simple,
], (configs, instancePolicy: ImmutableMap<string, any>) => { ], (configs, instancePolicy) => {
return instancePolicy.merge(ConfigDB.toSimplePolicy(configs)); return {
...instancePolicy,
...ConfigDB.toSimplePolicy(configs),
};
}); });
const getRemoteInstanceFavicon = (state: RootState, host: string) => { const getRemoteInstanceFavicon = (state: RootState, host: string) => {
@ -294,15 +298,24 @@ const getRemoteInstanceFavicon = (state: RootState, host: string) => {
return account?.pleroma?.favicon; return account?.pleroma?.favicon;
}; };
const getRemoteInstanceFederation = (state: RootState, host: string) => ( type HostFederation = {
getSimplePolicy(state) [key in keyof MRFSimple]: boolean;
.map(hosts => hosts.includes(host)) };
);
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 = () => { export const makeGetHosts = () => {
return createSelector([getSimplePolicy], (simplePolicy) => { return createSelector([getSimplePolicy], (simplePolicy) => {
return simplePolicy const { accept, reject_deletes, report_removal, ...rest } = simplePolicy;
.deleteAll(['accept', 'reject_deletes', 'report_removal'])
return Object.values(rest)
.reduce((acc, hosts) => acc.union(hosts), ImmutableOrderedSet()) .reduce((acc, hosts) => acc.union(hosts), ImmutableOrderedSet())
.sort(); .sort();
}); });

@ -20,7 +20,7 @@ export function connectStream(
callbacks: (dispatch: AppDispatch, getState: () => RootState) => ConnectStreamCallbacks, callbacks: (dispatch: AppDispatch, getState: () => RootState) => ConnectStreamCallbacks,
) { ) {
return (dispatch: AppDispatch, getState: () => RootState) => { 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 accessToken = getAccessToken(getState());
const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState); const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);

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

@ -64,6 +64,6 @@ export const getAuthUserUrl = (state: RootState) => {
/** Get the VAPID public key. */ /** Get the VAPID public key. */
export const getVapidKey = (state: RootState) => 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; export const getMeUrl = (state: RootState) => selectOwnAccount(state)?.url;

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

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

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

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

@ -18,7 +18,7 @@ export const displayFqn = (state: RootState): boolean => {
/** Whether the instance exposes instance blocks through the API. */ /** Whether the instance exposes instance blocks through the API. */
export const federationRestrictionsDisclosed = (state: RootState): boolean => { 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