From 3b630ed8fbc052e2661c3f8cafc6c8587e7ab5e7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 23 Sep 2023 20:41:24 -0500 Subject: [PATCH 1/2] Convert instance to use zod --- src/actions/__tests__/accounts.test.ts | 24 ++-- src/actions/__tests__/announcements.test.ts | 5 +- src/actions/__tests__/compose.test.ts | 32 ++--- src/actions/compose.ts | 2 +- src/actions/external-auth.ts | 7 +- src/actions/media.ts | 6 +- src/actions/mrf.ts | 12 +- src/api/hooks/nostr/useSignerStream.ts | 6 +- src/api/hooks/streaming/useTimelineStream.ts | 2 +- src/components/birthday-input.tsx | 2 +- src/components/translate-button.tsx | 12 +- .../components/registration-mode-picker.tsx | 3 +- src/features/admin/tabs/dashboard.tsx | 10 +- .../auth-login/components/consumers-list.tsx | 5 +- .../components/registration-form.tsx | 4 +- .../__tests__/chat-message-list.test.tsx | 6 +- .../chats/components/chat-composer.tsx | 4 +- src/features/chats/components/chat.tsx | 2 +- .../compose/components/compose-form.tsx | 2 +- .../compose/components/polls/poll-form.tsx | 8 +- .../compose/components/upload-button.tsx | 10 +- .../edit-profile/components/avatar-picker.tsx | 2 +- .../edit-profile/components/header-picker.tsx | 2 +- src/features/edit-profile/index.tsx | 8 +- .../components/instance-restrictions.tsx | 2 +- src/features/group/edit-group.tsx | 12 +- src/features/migration/index.tsx | 2 +- .../compose-event-modal/upload-button.tsx | 7 +- .../manage-group-modal/steps/details-step.tsx | 12 +- src/jest/factory.ts | 7 ++ src/reducers/__tests__/instance.test.ts | 11 +- src/reducers/instance.ts | 59 +++------- src/schemas/attachment.ts | 4 +- src/schemas/index.ts | 1 + src/schemas/instance.ts | 111 ++++++++++++++++++ src/schemas/pleroma.ts | 20 ++++ src/schemas/utils.ts | 10 +- src/selectors/index.ts | 31 +++-- src/stream.ts | 2 +- src/utils/__tests__/features.test.ts | 24 ++-- src/utils/auth.ts | 2 +- src/utils/config-db.ts | 9 +- src/utils/features.ts | 9 +- src/utils/quirks.ts | 2 +- src/utils/scopes.ts | 6 +- src/utils/state.ts | 2 +- 46 files changed, 326 insertions(+), 195 deletions(-) create mode 100644 src/schemas/instance.ts create mode 100644 src/schemas/pleroma.ts diff --git a/src/actions/__tests__/accounts.test.ts b/src/actions/__tests__/accounts.test.ts index 22082c530..9f264ab34 100644 --- a/src/actions/__tests__/accounts.test.ts +++ b/src/actions/__tests__/accounts.test.ts @@ -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); diff --git a/src/actions/__tests__/announcements.test.ts b/src/actions/__tests__/announcements.test.ts index 978311585..5295873cd 100644 --- a/src/actions/__tests__/announcements.test.ts +++ b/src/actions/__tests__/announcements.test.ts @@ -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) => { diff --git a/src/actions/__tests__/compose.test.ts b/src/actions/__tests__/compose.test.ts index f6c64c929..7026e3aab 100644 --- a/src/actions/__tests__/compose.test.ts +++ b/src/actions/__tests__/compose.test.ts @@ -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; 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; 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 diff --git a/src/actions/compose.ts b/src/actions/compose.ts index c09b0ef10..c28618e11 100644 --- a/src/actions/compose.ts +++ b/src/actions/compose.ts @@ -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); diff --git a/src/actions/external-auth.ts b/src/actions/external-auth.ts index 40f7cfc21..38ae92800 100644 --- a/src/actions/external-auth.ts +++ b/src/actions/external-auth.ts @@ -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; } diff --git a/src/actions/media.ts b/src/actions/media.ts index 112d0e02b..ed8d25fa0 100644 --- a/src/actions/media.ts +++ b/src/actions/media.ts @@ -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.*/); diff --git a/src/actions/mrf.ts b/src/actions/mrf.ts index 1b9cbad93..359d7711f 100644 --- a/src/actions/mrf.ts +++ b/src/actions/mrf.ts @@ -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) => { - return simplePolicy.map((hosts, key) => { +const simplePolicyMerge = (simplePolicy: MRFSimple, host: string, restrictions: ImmutableMap) => { + 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) => diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index ae86b7fbe..b64fc132b 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useSignerStream.ts @@ -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; diff --git a/src/api/hooks/streaming/useTimelineStream.ts b/src/api/hooks/streaming/useTimelineStream.ts index 28998e090..37ee40f1e 100644 --- a/src/api/hooks/streaming/useTimelineStream.ts +++ b/src/api/hooks/streaming/useTimelineStream.ts @@ -14,7 +14,7 @@ function useTimelineStream(...args: Parameters) { 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) { diff --git a/src/components/birthday-input.tsx b/src/components/birthday-input.tsx index 0d21ed287..410f3d202 100644 --- a/src/components/birthday-input.tsx +++ b/src/components/birthday-input.tsx @@ -26,7 +26,7 @@ const BirthdayInput: React.FC = ({ 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; diff --git a/src/components/translate-button.tsx b/src/components/translate-button.tsx index 68358ff60..f330cad5e 100644 --- a/src/components/translate-button.tsx +++ b/src/components/translate-button.tsx @@ -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 = ({ 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; - const targetLanguages = instance.pleroma.getIn(['metadata', 'translation', 'target_languages']) as ImmutableList; + 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; diff --git a/src/features/admin/components/registration-mode-picker.tsx b/src/features/admin/components/registration-mode-picker.tsx index eea8f944b..51d18ab43 100644 --- a/src/features/admin/components/registration-mode-picker.tsx +++ b/src/features/admin/components/registration-mode-picker.tsx @@ -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({ diff --git a/src/features/admin/tabs/dashboard.tsx b/src/features/admin/tabs/dashboard.tsx index b8b9efcac..8808b9632 100644 --- a/src/features/admin/tabs/dashboard.tsx +++ b/src/features/admin/tabs/dashboard.tsx @@ -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; diff --git a/src/features/auth-login/components/consumers-list.tsx b/src/features/auth-login/components/consumers-list.tsx index 784077af9..e55e2a512 100644 --- a/src/features/auth-login/components/consumers-list.tsx +++ b/src/features/auth-login/components/consumers-list.tsx @@ -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 = () => { const instance = useInstance(); - const providers = ImmutableList(instance.pleroma.get('oauth_consumer_strategies')); + const providers = instance.pleroma.oauth_consumer_strategies; - if (providers.size > 0) { + if (providers.length > 0) { return ( diff --git a/src/features/auth-login/components/registration-form.tsx b/src/features/auth-login/components/registration-form.tsx index dd0bab511..417751815 100644 --- a/src/features/auth-login/components/registration-form.tsx +++ b/src/features/auth-login/components/registration-form.tsx @@ -46,11 +46,11 @@ const RegistrationForm: React.FC = ({ 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); diff --git a/src/features/chats/components/__tests__/chat-message-list.test.tsx b/src/features/chats/components/__tests__/chat-message-list.test.tsx index 507608c99..b73cc2352 100644 --- a/src/features/chats/components/__tests__/chat-message-list.test.tsx +++ b/src/features/chats/components/__tests__/chat-message-list.test.tsx @@ -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( diff --git a/src/features/chats/components/chat-composer.tsx b/src/features/chats/components/chat-composer.tsx index c5e4900e2..41335e50a 100644 --- a/src/features/chats/components/chat-composer.tsx +++ b/src/features/chats/components/chat-composer.tsx @@ -76,8 +76,8 @@ const ChatComposer = React.forwardRef 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(initialSuggestionState); const isSuggestionsAvailable = suggestions.list.length > 0; diff --git a/src/features/chats/components/chat.tsx b/src/features/chats/components/chat.tsx index b12d01ebe..921f8f142 100644 --- a/src/features/chats/components/chat.tsx +++ b/src/features/chats/components/chat.tsx @@ -53,7 +53,7 @@ const Chat: React.FC = ({ 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(''); const [attachments, setAttachments] = useState([]); diff --git a/src/features/compose/components/compose-form.tsx b/src/features/compose/components/compose-form.tsx index db316b138..0994ca00b 100644 --- a/src/features/compose/components/compose-form.tsx +++ b/src/features/compose/components/compose-form.tsx @@ -77,7 +77,7 @@ const ComposeForm = ({ 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(); diff --git a/src/features/compose/components/polls/poll-form.tsx b/src/features/compose/components/polls/poll-form.tsx index 9dc5b6f6d..020fd2c33 100644 --- a/src/features/compose/components/polls/poll-form.tsx +++ b/src/features/compose/components/polls/poll-form.tsx @@ -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 = ({ composeId }) => { const compose = useCompose(composeId); - const pollLimits = configuration.get('polls') as ImmutableMap; 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)); diff --git a/src/features/compose/components/upload-button.tsx b/src/features/compose/components/upload-button.tsx index 82adc529d..ab5f9d71f 100644 --- a/src/features/compose/components/upload-button.tsx +++ b/src/features/compose/components/upload-button.tsx @@ -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) => { - 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 = ({ const { configuration } = useInstance(); const fileElement = useRef(null); - const attachmentTypes = configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList; + const attachmentTypes = configuration.media_attachments.supported_mime_types; const handleChange: React.ChangeEventHandler = (e) => { if (e.target.files?.length) { @@ -78,7 +76,7 @@ const UploadButton: React.FC = ({ ref={fileElement} type='file' multiple - accept={attachmentTypes && attachmentTypes.toArray().join(',')} + accept={attachmentTypes?.join(',')} onChange={handleChange} disabled={disabled} className='hidden' diff --git a/src/features/edit-profile/components/avatar-picker.tsx b/src/features/edit-profile/components/avatar-picker.tsx index afbd54a71..d8a72a40c 100644 --- a/src/features/edit-profile/components/avatar-picker.tsx +++ b/src/features/edit-profile/components/avatar-picker.tsx @@ -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 } diff --git a/src/features/edit-profile/components/header-picker.tsx b/src/features/edit-profile/components/header-picker.tsx index 66d3c390f..345c71842 100644 --- a/src/features/edit-profile/components/header-picker.tsx +++ b/src/features/edit-profile/components/header-picker.tsx @@ -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 diff --git a/src/features/edit-profile/index.tsx b/src/features/edit-profile/index.tsx index 7669a8682..ea20debee 100644 --- a/src/features/edit-profile/index.tsx +++ b/src/features/edit-profile/index.tsx @@ -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, - )?.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({}); diff --git a/src/features/federation-restrictions/components/instance-restrictions.tsx b/src/features/federation-restrictions/components/instance-restrictions.tsx index f4ae397cd..71c9c9e58 100644 --- a/src/features/federation-restrictions/components/instance-restrictions.tsx +++ b/src/features/federation-restrictions/components/instance-restrictions.tsx @@ -111,7 +111,7 @@ const InstanceRestrictions: React.FC = ({ 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 ( diff --git a/src/features/group/edit-group.tsx b/src/features/group/edit-group.tsx index 94bbee1e1..c58d22d17 100644 --- a/src/features/group/edit-group.tsx +++ b/src/features/group/edit-group.tsx @@ -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 = ({ 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, - )?.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); diff --git a/src/features/migration/index.tsx b/src/features/migration/index.tsx index 0915e016e..b563308cf 100644 --- a/src/features/migration/index.tsx +++ b/src/features/migration/index.tsx @@ -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(''); diff --git a/src/features/ui/components/modals/compose-event-modal/upload-button.tsx b/src/features/ui/components/modals/compose-event-modal/upload-button.tsx index eb99634b2..51a81bbd9 100644 --- a/src/features/ui/components/modals/compose-event-modal/upload-button.tsx +++ b/src/features/ui/components/modals/compose-event-modal/upload-button.tsx @@ -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 = ({ disabled, onSelectFile }) => { const fileElement = useRef(null); - const attachmentTypes = useAppSelector(state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList)?.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 = (e) => { if (e.target.files?.length) { @@ -40,7 +39,7 @@ const UploadButton: React.FC = ({ disabled, onSelectFile }) => { = ({ 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, - )?.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 => { return (e) => { @@ -107,7 +105,7 @@ const DetailsStep: React.FC = ({ 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)} /> @@ -119,7 +117,7 @@ const DetailsStep: React.FC = ({ 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)} /> diff --git a/src/jest/factory.ts b/src/jest/factory.ts index df0437719..3bb61f77c 100644 --- a/src/jest/factory.ts +++ b/src/jest/factory.ts @@ -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 = {}) { + return instanceSchema.parse(props); +} + function buildRelationship(props: PartialDeep = {}): Relationship { return relationshipSchema.parse(Object.assign({ id: uuidv4(), @@ -91,6 +97,7 @@ export { buildGroupMember, buildGroupRelationship, buildGroupTag, + buildInstance, buildRelationship, buildStatus, }; \ No newline at end of file diff --git a/src/reducers/__tests__/instance.test.ts b/src/reducers/__tests__/instance.test.ts index 3c54692f0..51903b1ef 100644 --- a/src/reducers/__tests__/instance.test.ts +++ b/src/reducers/__tests__/instance.test.ts @@ -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); }); }); diff --git a/src/reducers/instance.ts b/src/reducers/instance.ts index 1c15891e9..2ccb89325 100644 --- a/src/reducers/instance.ts +++ b/src/reducers/instance.ts @@ -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) => { - // 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) => { - return normalizeInstance(instance); -}; +const initialState: Instance = instanceSchema.parse({}); -const importNodeinfo = (state: typeof initialState, nodeinfo: ImmutableMap) => { - return nodeinfoToInstance(nodeinfo).mergeDeep(state); +const importInstance = (_state: typeof initialState, instance: unknown) => { + return instanceSchema.parse(instance); }; const preloadImport = (state: typeof initialState, action: Record, 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, key: string) => { @@ -59,28 +39,29 @@ const importConfigs = (state: typeof initialState, configs: ImmutableList) 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))); diff --git a/src/schemas/attachment.ts b/src/schemas/attachment.ts index 502de94e1..dfc880f58 100644 --- a/src/schemas/attachment.ts +++ b/src/schemas/attachment.ts @@ -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), diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 81b507ce5..2fd3cc3de 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -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'; diff --git a/src/schemas/instance.ts b/src/schemas/instance.ts new file mode 100644 index 000000000..c2f72b8ff --- /dev/null +++ b/src/schemas/instance.ts @@ -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; + +export { instanceSchema, Instance }; diff --git a/src/schemas/pleroma.ts b/src/schemas/pleroma.ts new file mode 100644 index 000000000..04f8bf580 --- /dev/null +++ b/src/schemas/pleroma.ts @@ -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; + +export { mrfSimpleSchema, type MRFSimple }; \ No newline at end of file diff --git a/src/schemas/utils.ts b/src/schemas/utils.ts index e8172ad9e..34312a41d 100644 --- a/src/schemas/utils.ts +++ b/src/schemas/utils.ts @@ -39,4 +39,12 @@ const jsonSchema = z.string().transform((value, ctx) => { } }); -export { filteredArray, makeCustomEmojiMap, emojiSchema, contentSchema, dateSchema, jsonSchema }; \ No newline at end of file +/** 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(shape: T) { + return z.object({}).passthrough().catch({}).pipe(z.object(shape)); +} + +export { filteredArray, makeCustomEmojiMap, emojiSchema, contentSchema, dateSchema, jsonSchema, mimeSchema, coerceObject }; \ No newline at end of file diff --git a/src/selectors/index.ts b/src/selectors/index.ts index 1f844bc27..a6b52a641 100644 --- a/src/selectors/index.ts +++ b/src/selectors/index.ts @@ -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, -], (configs, instancePolicy: ImmutableMap) => { - 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(); }); diff --git a/src/stream.ts b/src/stream.ts index a8bb88b63..146dc21f5 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -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); diff --git a/src/utils/__tests__/features.test.ts b/src/utils/__tests__/features.test.ts index da9985b13..090d05475 100644 --- a/src/utils/__tests__/features.test.ts +++ b/src/utils/__tests__/features.test.ts @@ -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); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 2d94e1054..b5d9fb120 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -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; \ No newline at end of file diff --git a/src/utils/config-db.ts b/src/utils/config-db.ts index 8eedca515..5faf18163 100644 --- a/src/utils/config-db.ts +++ b/src/utils/config-db.ts @@ -6,6 +6,8 @@ import { } from 'immutable'; import trimStart from 'lodash/trimStart'; +import { type MRFSimple, mrfSimpleSchema } from 'soapbox/schemas/pleroma'; + export type Config = ImmutableMap; export type Policy = ImmutableMap; @@ -19,7 +21,7 @@ const find = ( ); }; -const toSimplePolicy = (configs: ImmutableList): Policy => { +const toSimplePolicy = (configs: ImmutableList): MRFSimple => { const config = find(configs, ':pleroma', ':mrf_simple'); const reducer = (acc: ImmutableMap, curr: ImmutableMap) => { @@ -30,9 +32,10 @@ const toSimplePolicy = (configs: ImmutableList): 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({}); } }; diff --git a/src/utils/features.ts b/src/utils/features.ts index 2beae84e4..fb3a5e191 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -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; - const federation = instance.pleroma.getIn(['metadata', 'federation'], ImmutableMap()) as ImmutableMap; + 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"). diff --git a/src/utils/quirks.ts b/src/utils/quirks.ts index 801556684..9024c67be 100644 --- a/src/utils/quirks.ts +++ b/src/utils/quirks.ts @@ -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([ diff --git a/src/utils/scopes.ts b/src/utils/scopes.ts index 67862936d..9a6175952 100644 --- a/src/utils/scopes.ts +++ b/src/utils/scopes.ts @@ -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, diff --git a/src/utils/state.ts b/src/utils/state.ts index 9563bd542..f2cf27660 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -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; }; /** From 17bcd68d73c89287fa155e2f466055cc71cca862 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 23 Sep 2023 20:56:37 -0500 Subject: [PATCH 2/2] Fix remote timeline pages --- .../components/instance-restrictions.tsx | 6 ++---- .../ui/components/modals/edit-federation-modal.tsx | 7 +++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/features/federation-restrictions/components/instance-restrictions.tsx b/src/features/federation-restrictions/components/instance-restrictions.tsx index 71c9c9e58..154c2aba6 100644 --- a/src/features/federation-restrictions/components/instance-restrictions.tsx +++ b/src/features/federation-restrictions/components/instance-restrictions.tsx @@ -8,10 +8,8 @@ import { useInstance } from 'soapbox/hooks'; import type { Map as ImmutableMap } from 'immutable'; const hasRestrictions = (remoteInstance: ImmutableMap): 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 { diff --git a/src/features/ui/components/modals/edit-federation-modal.tsx b/src/features/ui/components/modals/edit-federation-modal.tsx index a8851420f..f438d8258 100644 --- a/src/features/ui/components/modals/edit-federation-modal.tsx +++ b/src/features/ui/components/modals/edit-federation-modal.tsx @@ -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 = ({ host, onClose }) const getRemoteInstance = useCallback(makeGetRemoteInstance(), []); const remoteInstance = useAppSelector(state => getRemoteInstance(state, host)); - const [data, setData] = useState(ImmutableMap()); + const [data, setData] = useState({} as any); useEffect(() => { - setData(remoteInstance.get('federation') as any); + setData(remoteInstance.get('federation')); }, [remoteInstance]); const handleDataChange = (key: string): React.ChangeEventHandler => { @@ -69,7 +68,7 @@ const EditFederationModal: React.FC = ({ host, onClose }) media_nsfw, media_removal, reject, - } = data.toJS() as Record; + } = data; const fullMediaRemoval = avatar_removal && banner_removal && media_removal;