diff --git a/src/actions/instance.ts b/src/actions/instance.ts index 6ea62ca96..c6ccd053b 100644 --- a/src/actions/instance.ts +++ b/src/actions/instance.ts @@ -1,10 +1,11 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import get from 'lodash/get'; +import { gte } from 'semver'; import KVStore from 'soapbox/storage/kv-store'; import { RootState } from 'soapbox/store'; import { getAuthUserUrl, getMeUrl } from 'soapbox/utils/auth'; -import { parseVersion } from 'soapbox/utils/features'; +import { MASTODON, parseVersion, PLEROMA, REBASED } from 'soapbox/utils/features'; import api from '../api'; @@ -22,25 +23,50 @@ export const getHost = (state: RootState) => { export const rememberInstance = createAsyncThunk( 'instance/remember', async(host: string) => { - return await KVStore.getItemOrError(`instance:${host}`); + const instance = await KVStore.getItemOrError(`instance:${host}`); + + return { instance, host }; }, ); +const supportsInstanceV2 = (instance: Record): boolean => { + const v = parseVersion(get(instance, 'version')); + return (v.software === MASTODON && gte(v.compatVersion, '4.0.0')) || + (v.software === PLEROMA && v.build === REBASED && gte(v.version, '2.5.54')); +}; + /** We may need to fetch nodeinfo on Pleroma < 2.1 */ const needsNodeinfo = (instance: Record): boolean => { const v = parseVersion(get(instance, 'version')); - return v.software === 'Pleroma' && !get(instance, ['pleroma', 'metadata']); + return v.software === PLEROMA && !get(instance, ['pleroma', 'metadata']); }; -export const fetchInstance = createAsyncThunk( +export const fetchInstance = createAsyncThunk<{ instance: Record; host?: string | null }, string | null | undefined, { state: RootState }>( 'instance/fetch', - async(_arg, { dispatch, getState, rejectWithValue }) => { + async(host, { dispatch, getState, rejectWithValue }) => { try { const { data: instance } = await api(getState).get('/api/v1/instance'); + + if (supportsInstanceV2(instance)) { + return dispatch(fetchInstanceV2(host)) as any as { instance: Record; host?: string | null }; + } + if (needsNodeinfo(instance)) { dispatch(fetchNodeinfo()); } - return instance; + return { instance, host }; + } catch (e) { + return rejectWithValue(e); + } + }, +); + +export const fetchInstanceV2 = createAsyncThunk<{ instance: Record; host?: string | null }, string | null | undefined, { state: RootState }>( + 'instance/fetch', + async(host, { getState, rejectWithValue }) => { + try { + const { data: instance } = await api(getState).get('/api/v2/instance'); + return { instance, host }; } catch (e) { return rejectWithValue(e); } @@ -52,10 +78,13 @@ export const loadInstance = createAsyncThunk( 'instance/load', async(_arg, { dispatch, getState }) => { const host = getHost(getState()); - await Promise.all([ - dispatch(rememberInstance(host || '')), - dispatch(fetchInstance()), - ]); + const rememberedInstance = await dispatch(rememberInstance(host || '')); + + if (rememberedInstance.payload && supportsInstanceV2((rememberedInstance.payload as any).instance)) { + await dispatch(fetchInstanceV2(host)); + } else { + await dispatch(fetchInstance(host)); + } }, ); diff --git a/src/api/hooks/groups/useGroups.test.ts b/src/api/hooks/groups/useGroups.test.ts index 950c4eb81..65a699969 100644 --- a/src/api/hooks/groups/useGroups.test.ts +++ b/src/api/hooks/groups/useGroups.test.ts @@ -1,13 +1,13 @@ import { __stub } from 'soapbox/api'; import { buildGroup } from 'soapbox/jest/factory'; import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; -import { normalizeInstance } from 'soapbox/normalizers'; +import { instanceSchema } from 'soapbox/schemas'; import { useGroups } from './useGroups'; const group = buildGroup({ id: '1', display_name: 'soapbox' }); const store = { - instance: normalizeInstance({ + instance: instanceSchema.parse({ version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)', }), }; diff --git a/src/api/hooks/groups/usePendingGroups.test.ts b/src/api/hooks/groups/usePendingGroups.test.ts index f2f76178c..33190ae0b 100644 --- a/src/api/hooks/groups/usePendingGroups.test.ts +++ b/src/api/hooks/groups/usePendingGroups.test.ts @@ -2,14 +2,14 @@ import { __stub } from 'soapbox/api'; import { Entities } from 'soapbox/entity-store/entities'; import { buildAccount, buildGroup } from 'soapbox/jest/factory'; import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; -import { normalizeInstance } from 'soapbox/normalizers'; +import { instanceSchema } from 'soapbox/schemas'; import { usePendingGroups } from './usePendingGroups'; const id = '1'; const group = buildGroup({ id, display_name: 'soapbox' }); const store = { - instance: normalizeInstance({ + instance: instanceSchema.parse({ version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)', }), me: '1', diff --git a/src/api/hooks/streaming/useTimelineStream.ts b/src/api/hooks/streaming/useTimelineStream.ts index 37ee40f1e..22e9d0cf4 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?.streaming_api; + const streamingUrl = instance.configuration.urls.streaming; const connect = () => { if (enabled && streamingUrl && !stream.current) { diff --git a/src/features/admin/components/registration-mode-picker.tsx b/src/features/admin/components/registration-mode-picker.tsx index 51d18ab43..744809a32 100644 --- a/src/features/admin/components/registration-mode-picker.tsx +++ b/src/features/admin/components/registration-mode-picker.tsx @@ -27,9 +27,9 @@ const generateConfig = (mode: RegistrationMode) => { }]; }; -const modeFromInstance = (instance: Instance): RegistrationMode => { - if (instance.approval_required && instance.registrations) return 'approval'; - return instance.registrations ? 'open' : 'closed'; +const modeFromInstance = ({ registrations }: Instance): RegistrationMode => { + if (registrations.approval_required && registrations.enabled) return 'approval'; + return registrations.enabled ? 'open' : 'closed'; }; /** Allows changing the registration mode of the instance, eg "open", "closed", "approval" */ diff --git a/src/features/auth-login/components/login-form.test.tsx b/src/features/auth-login/components/login-form.test.tsx index cb14335c2..2cecaa059 100644 --- a/src/features/auth-login/components/login-form.test.tsx +++ b/src/features/auth-login/components/login-form.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { fireEvent, render, screen } from 'soapbox/jest/test-helpers'; -import { normalizeInstance } from 'soapbox/normalizers'; +import { instanceSchema } from 'soapbox/schemas'; import LoginForm from './login-form'; @@ -9,7 +9,7 @@ describe('', () => { it('renders for Pleroma', () => { const mockFn = vi.fn(); const store = { - instance: normalizeInstance({ + instance: instanceSchema.parse({ version: '2.7.2 (compatible; Pleroma 2.3.0)', }), }; @@ -22,7 +22,7 @@ describe('', () => { it('renders for Mastodon', () => { const mockFn = vi.fn(); const store = { - instance: normalizeInstance({ + instance: instanceSchema.parse({ version: '3.0.0', }), }; diff --git a/src/features/auth-login/components/login-page.test.tsx b/src/features/auth-login/components/login-page.test.tsx index 58538bebf..fc308ff4f 100644 --- a/src/features/auth-login/components/login-page.test.tsx +++ b/src/features/auth-login/components/login-page.test.tsx @@ -1,14 +1,14 @@ import React from 'react'; import { render, screen } from 'soapbox/jest/test-helpers'; -import { normalizeInstance } from 'soapbox/normalizers'; +import { instanceSchema } from 'soapbox/schemas'; import LoginPage from './login-page'; describe('', () => { it('renders correctly on load', () => { const store = { - instance: normalizeInstance({ + instance: instanceSchema.parse({ version: '2.7.2 (compatible; Pleroma 2.3.0)', }), }; diff --git a/src/features/auth-login/components/registration-form.tsx b/src/features/auth-login/components/registration-form.tsx index 1b19b0b37..5d39eb402 100644 --- a/src/features/auth-login/components/registration-form.tsx +++ b/src/features/auth-login/components/registration-form.tsx @@ -47,7 +47,7 @@ const RegistrationForm: React.FC = ({ inviteToken }) => { const locale = settings.get('locale'); const needsConfirmation = instance.pleroma.metadata.account_activation_required; - const needsApproval = instance.approval_required; + const needsApproval = instance.registrations.approval_required; const supportsEmailList = features.emailList; const supportsAccountLookup = features.accountLookup; const birthdayRequired = instance.pleroma.metadata.birthday_required; diff --git a/src/features/compose/components/upload.tsx b/src/features/compose/components/upload.tsx index 4cb163553..a7f89d487 100644 --- a/src/features/compose/components/upload.tsx +++ b/src/features/compose/components/upload.tsx @@ -12,7 +12,7 @@ interface IUploadCompose { const UploadCompose: React.FC = ({ composeId, id, onSubmit }) => { const dispatch = useAppDispatch(); - const { description_limit: descriptionLimit } = useInstance(); + const { pleroma: { metadata: { description_limit: descriptionLimit } } } = useInstance(); const media = useCompose(composeId).media_attachments.find(item => item.id === id)!; diff --git a/src/features/groups/components/discover/search/search.test.tsx b/src/features/groups/components/discover/search/search.test.tsx index f7dbafa3d..5c25cc5a3 100644 --- a/src/features/groups/components/discover/search/search.test.tsx +++ b/src/features/groups/components/discover/search/search.test.tsx @@ -3,12 +3,12 @@ import React from 'react'; import { __stub } from 'soapbox/api'; import { buildGroup } from 'soapbox/jest/factory'; import { render, screen, waitFor } from 'soapbox/jest/test-helpers'; -import { normalizeInstance } from 'soapbox/normalizers'; +import { instanceSchema } from 'soapbox/schemas'; import Search from './search'; const store = { - instance: normalizeInstance({ + instance: instanceSchema.parse({ version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)', }), }; diff --git a/src/features/groups/discover.test.tsx b/src/features/groups/discover.test.tsx index 1f14f1022..880b17c1c 100644 --- a/src/features/groups/discover.test.tsx +++ b/src/features/groups/discover.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { buildAccount } from 'soapbox/jest/factory'; import { render, screen, waitFor } from 'soapbox/jest/test-helpers'; -import { normalizeInstance } from 'soapbox/normalizers'; +import { instanceSchema } from 'soapbox/schemas'; import Discover from './discover'; @@ -32,7 +32,7 @@ const store: any = { }, }), }, - instance: normalizeInstance({ + instance: instanceSchema.parse({ version: '3.4.1 (compatible; TruthSocial 1.0.0)', software: 'TRUTHSOCIAL', }), diff --git a/src/features/landing-timeline/components/site-banner.tsx b/src/features/landing-timeline/components/site-banner.tsx index 453447c9a..194faecc6 100644 --- a/src/features/landing-timeline/components/site-banner.tsx +++ b/src/features/landing-timeline/components/site-banner.tsx @@ -9,7 +9,7 @@ import { LogoText } from './logo-text'; const SiteBanner: React.FC = () => { const instance = useInstance(); - const description = instance.short_description || instance.description; + const description = instance.description; return ( diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index 67596066a..3fafe2a00 100644 --- a/src/features/ui/index.tsx +++ b/src/features/ui/index.tsx @@ -339,7 +339,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => - {(features.accountCreation && instance.registrations) && ( + {(features.accountCreation && instance.registrations.enabled) && ( )} diff --git a/src/hooks/useGroupsPath.test.ts b/src/hooks/useGroupsPath.test.ts index a7ba8979b..72af53731 100644 --- a/src/hooks/useGroupsPath.test.ts +++ b/src/hooks/useGroupsPath.test.ts @@ -1,14 +1,14 @@ import { __stub } from 'soapbox/api'; import { buildAccount, buildGroup, buildGroupRelationship } from 'soapbox/jest/factory'; import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; -import { normalizeInstance } from 'soapbox/normalizers'; +import { instanceSchema } from 'soapbox/schemas'; import { useGroupsPath } from './useGroupsPath'; describe('useGroupsPath()', () => { test('without the groupsDiscovery feature', () => { const store = { - instance: normalizeInstance({ + instance: instanceSchema.parse({ version: '2.7.2 (compatible; Pleroma 2.3.0)', }), }; @@ -24,7 +24,7 @@ describe('useGroupsPath()', () => { beforeEach(() => { const userId = '1'; store = { - instance: normalizeInstance({ + instance: instanceSchema.parse({ version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)', }), me: userId, diff --git a/src/hooks/useRegistrationStatus.ts b/src/hooks/useRegistrationStatus.ts index aac5efef6..736c2d8cd 100644 --- a/src/hooks/useRegistrationStatus.ts +++ b/src/hooks/useRegistrationStatus.ts @@ -7,6 +7,6 @@ export const useRegistrationStatus = () => { return { /** Registrations are open. */ - isOpen: features.accountCreation && instance.registrations, + isOpen: features.accountCreation && instance.registrations.enabled, }; }; \ No newline at end of file diff --git a/src/jest/mock-stores.tsx b/src/jest/mock-stores.tsx index 0dc4bae79..c3ba64aac 100644 --- a/src/jest/mock-stores.tsx +++ b/src/jest/mock-stores.tsx @@ -1,13 +1,13 @@ import alexJson from 'soapbox/__fixtures__/pleroma-account.json'; -import { normalizeInstance } from 'soapbox/normalizers'; +import { instanceSchema } from 'soapbox/schemas'; import { buildAccount } from './factory'; /** Store with registrations open. */ -const storeOpen = { instance: normalizeInstance({ registrations: true }) }; +const storeOpen = { instance: instanceSchema.parse({ registrations: true }) }; /** Store with registrations closed. */ -const storeClosed = { instance: normalizeInstance({ registrations: false }) }; +const storeClosed = { instance: instanceSchema.parse({ registrations: false }) }; /** Store with a logged-in user. */ const storeLoggedIn = { diff --git a/src/normalizers/index.ts b/src/normalizers/index.ts index 12bb77d0c..03fb83021 100644 --- a/src/normalizers/index.ts +++ b/src/normalizers/index.ts @@ -13,7 +13,6 @@ export { FilterStatusRecord, normalizeFilterStatus } from './filter-status'; export { normalizeGroup } from './group'; export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship'; export { HistoryRecord, normalizeHistory } from './history'; -export { InstanceRecord, normalizeInstance } from './instance'; export { ListRecord, normalizeList } from './list'; export { LocationRecord, normalizeLocation } from './location'; export { MentionRecord, normalizeMention } from './mention'; diff --git a/src/normalizers/instance.test.ts b/src/normalizers/instance.test.ts deleted file mode 100644 index 46793dfaf..000000000 --- a/src/normalizers/instance.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { Map as ImmutableMap, fromJS } from 'immutable'; - -import { normalizeInstance } from './instance'; - -describe('normalizeInstance()', () => { - it('normalizes an empty Map', () => { - const expected = { - approval_required: false, - contact_account: {}, - configuration: { - media_attachments: {}, - chats: { - max_characters: 5000, - max_media_attachments: 1, - }, - polls: { - max_options: 4, - max_characters_per_option: 25, - min_expiration: 300, - max_expiration: 2629746, - }, - statuses: { - max_characters: 500, - max_media_attachments: 4, - }, - groups: { - max_characters_name: 50, - max_characters_description: 160, - }, - }, - description: '', - description_limit: 1500, - email: '', - feature_quote: false, - fedibird_capabilities: [], - invites_enabled: false, - languages: [], - login_message: '', - pleroma: { - metadata: { - account_activation_required: false, - birthday_min_age: 0, - birthday_required: false, - features: [], - federation: { - enabled: true, - exclusions: false, - }, - }, - stats: {}, - }, - registrations: false, - rules: [], - short_description: '', - stats: { - domain_count: 0, - status_count: 0, - user_count: 0, - }, - title: '', - thumbnail: '', - uri: '', - urls: {}, - version: '0.0.0', - nostr: { - pubkey: undefined, - relay: undefined, - }, - }; - - const result = normalizeInstance(ImmutableMap()); - expect(result.toJS()).toEqual(expected); - }); - - it('normalizes Pleroma instance with Mastodon configuration format', async () => { - const instance = await import('soapbox/__fixtures__/pleroma-instance.json'); - - const expected = { - configuration: { - statuses: { - max_characters: 5000, - max_media_attachments: Infinity, - }, - polls: { - max_options: 20, - max_characters_per_option: 200, - min_expiration: 0, - max_expiration: 31536000, - }, - }, - }; - - const result = normalizeInstance(instance); - expect(result.toJS()).toMatchObject(expected); - }); - - it('normalizes Mastodon instance with retained configuration', async () => { - const instance = await import('soapbox/__fixtures__/mastodon-instance.json'); - - const expected = { - configuration: { - statuses: { - max_characters: 500, - max_media_attachments: 4, - characters_reserved_per_url: 23, - }, - media_attachments: { - image_size_limit: 10485760, - image_matrix_limit: 16777216, - video_size_limit: 41943040, - video_frame_rate_limit: 60, - video_matrix_limit: 2304000, - }, - polls: { - max_options: 4, - max_characters_per_option: 50, - min_expiration: 300, - max_expiration: 2629746, - }, - }, - }; - - const result = normalizeInstance(instance); - expect(result.toJS()).toMatchObject(expected); - }); - - it('normalizes Mastodon 3.0.0 instance with default configuration', async () => { - const instance = await import('soapbox/__fixtures__/mastodon-3.0.0-instance.json'); - - const expected = { - configuration: { - statuses: { - max_characters: 500, - max_media_attachments: 4, - }, - polls: { - max_options: 4, - max_characters_per_option: 25, - min_expiration: 300, - max_expiration: 2629746, - }, - }, - }; - - const result = normalizeInstance(instance); - expect(result.toJS()).toMatchObject(expected); - }); - - it('normalizes Fedibird instance', async () => { - const instance = await import('soapbox/__fixtures__/fedibird-instance.json'); - const result = normalizeInstance(instance); - - // Sets description_limit - expect(result.description_limit).toEqual(1500); - - // Preserves fedibird_capabilities - expect(result.fedibird_capabilities).toEqual(fromJS(instance.fedibird_capabilities)); - }); - - it('normalizes Mitra instance', async () => { - const instance = await import('soapbox/__fixtures__/mitra-instance.json'); - const result = normalizeInstance(instance); - - // Adds configuration and description_limit - expect(result.get('configuration') instanceof ImmutableMap).toBe(true); - expect(result.get('description_limit')).toBe(1500); - }); - - it('normalizes GoToSocial instance', async () => { - const instance = await import('soapbox/__fixtures__/gotosocial-instance.json'); - const result = normalizeInstance(instance); - - // Normalizes max_toot_chars - expect(result.getIn(['configuration', 'statuses', 'max_characters'])).toEqual(5000); - expect(result.has('max_toot_chars')).toBe(false); - - // Adds configuration and description_limit - expect(result.get('configuration') instanceof ImmutableMap).toBe(true); - expect(result.get('description_limit')).toBe(1500); - }); - - it('normalizes Friendica instance', async () => { - const instance = await import('soapbox/__fixtures__/friendica-instance.json'); - const result = normalizeInstance(instance); - - // Normalizes max_toot_chars - expect(result.getIn(['configuration', 'statuses', 'max_characters'])).toEqual(200000); - expect(result.has('max_toot_chars')).toBe(false); - - // Adds configuration and description_limit - expect(result.get('configuration') instanceof ImmutableMap).toBe(true); - expect(result.get('description_limit')).toBe(1500); - }); - - it('normalizes a Mastodon RC version', async () => { - const instance = await import('soapbox/__fixtures__/mastodon-instance-rc.json'); - const result = normalizeInstance(instance); - - expect(result.version).toEqual('3.5.0-rc1'); - }); - - it('normalizes Pixelfed instance', async () => { - const instance = await import('soapbox/__fixtures__/pixelfed-instance.json'); - const result = normalizeInstance(instance); - expect(result.title).toBe('pixelfed'); - }); - - it('renames Akkoma to Pleroma', async () => { - const instance = await import('soapbox/__fixtures__/akkoma-instance.json'); - const result = normalizeInstance(instance); - - expect(result.version).toEqual('2.7.2 (compatible; Pleroma 2.4.50+akkoma)'); - }); -}); diff --git a/src/normalizers/instance.ts b/src/normalizers/instance.ts deleted file mode 100644 index ea3327fb6..000000000 --- a/src/normalizers/instance.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Instance normalizer: - * Converts API instances into our internal format. - * @see {@link https://docs.joinmastodon.org/entities/instance/} - */ -import { - Map as ImmutableMap, - List as ImmutableList, - Record as ImmutableRecord, - fromJS, -} from 'immutable'; - -import { parseVersion, PLEROMA } from 'soapbox/utils/features'; -import { mergeDefined } from 'soapbox/utils/normalizers'; -import { isNumber } from 'soapbox/utils/numbers'; - -// Use Mastodon defaults -// https://docs.joinmastodon.org/entities/instance/ -export const InstanceRecord = ImmutableRecord({ - approval_required: false, - contact_account: ImmutableMap(), - configuration: ImmutableMap({ - media_attachments: ImmutableMap(), - chats: ImmutableMap({ - max_characters: 5000, - max_media_attachments: 1, - }), - polls: ImmutableMap({ - max_options: 4, - max_characters_per_option: 25, - min_expiration: 300, - max_expiration: 2629746, - }), - statuses: ImmutableMap({ - max_characters: 500, - max_media_attachments: 4, - }), - groups: ImmutableMap({ - max_characters_name: 50, - max_characters_description: 160, - }), - }), - description: '', - description_limit: 1500, - email: '', - feature_quote: false, - fedibird_capabilities: ImmutableList(), - invites_enabled: false, - languages: ImmutableList(), - login_message: '', - pleroma: ImmutableMap({ - metadata: ImmutableMap({ - account_activation_required: false, - birthday_min_age: 0, - birthday_required: false, - features: ImmutableList(), - federation: ImmutableMap({ - enabled: true, - exclusions: false, - }), - }), - stats: ImmutableMap(), - }), - registrations: false, - rules: ImmutableList(), - short_description: '', - stats: ImmutableMap({ - domain_count: 0, - status_count: 0, - user_count: 0, - }), - nostr: ImmutableMap({ - relay: undefined as string | undefined, - pubkey: undefined as string | undefined, - }), - title: '', - thumbnail: '', - uri: '', - urls: ImmutableMap(), - version: '0.0.0', -}); - -// Build Mastodon configuration from Pleroma instance -const pleromaToMastodonConfig = (instance: ImmutableMap) => { - return ImmutableMap({ - statuses: ImmutableMap({ - max_characters: instance.get('max_toot_chars'), - }), - polls: ImmutableMap({ - max_options: instance.getIn(['poll_limits', 'max_options']), - max_characters_per_option: instance.getIn(['poll_limits', 'max_option_chars']), - min_expiration: instance.getIn(['poll_limits', 'min_expiration']), - max_expiration: instance.getIn(['poll_limits', 'max_expiration']), - }), - }); -}; - -// Get the software's default attachment limit -const getAttachmentLimit = (software: string | null) => software === PLEROMA ? Infinity : 4; - -// Normalize version -const normalizeVersion = (instance: ImmutableMap) => { - return instance.update('version', '0.0.0', version => { - // Handle Mastodon release candidates - if (new RegExp(/[0-9.]+rc[0-9]+/g).test(version)) { - return version.split('rc').join('-rc'); - } else { - return version; - } - }); -}; - -/** Rename Akkoma to Pleroma+akkoma */ -const fixAkkoma = (instance: ImmutableMap) => { - const version: string = instance.get('version', ''); - - if (version.includes('Akkoma')) { - return instance.set('version', '2.7.2 (compatible; Pleroma 2.4.50+akkoma)'); - } else { - return instance; - } -}; - -/** Set Takahē version to a Pleroma-like string */ -const fixTakahe = (instance: ImmutableMap) => { - const version: string = instance.get('version', ''); - - if (version.startsWith('takahe/')) { - return instance.set('version', `0.0.0 (compatible; Takahe ${version.slice(7)})`); - } else { - return instance; - } -}; - -// Normalize instance (Pleroma, Mastodon, etc.) to Mastodon's format -export const normalizeInstance = (instance: Record) => { - return InstanceRecord( - ImmutableMap(fromJS(instance)).withMutations((instance: ImmutableMap) => { - const { software } = parseVersion(instance.get('version')); - const mastodonConfig = pleromaToMastodonConfig(instance); - - // Merge configuration - instance.update('configuration', ImmutableMap(), configuration => ( - configuration.mergeDeepWith(mergeDefined, mastodonConfig) - )); - - // If max attachments isn't set, check the backend software - instance.updateIn(['configuration', 'statuses', 'max_media_attachments'], value => { - return isNumber(value) ? value : getAttachmentLimit(software); - }); - - // Urls can't be null, fix for Friendica - if (instance.get('urls') === null) instance.delete('urls'); - - // Normalize version - normalizeVersion(instance); - fixTakahe(instance); - fixAkkoma(instance); - - // Merge defaults - instance.mergeDeepWith(mergeDefined, InstanceRecord()); - }), - ); -}; diff --git a/src/reducers/instance.ts b/src/reducers/instance.ts index 2ccb89325..a6524207e 100644 --- a/src/reducers/instance.ts +++ b/src/reducers/instance.ts @@ -10,13 +10,15 @@ import { ConfigDB } from 'soapbox/utils/config-db'; import { rememberInstance, fetchInstance, + fetchInstanceV2, } from '../actions/instance'; import type { AnyAction } from 'redux'; +import type { APIEntity } from 'soapbox/types/entities'; const initialState: Instance = instanceSchema.parse({}); -const importInstance = (_state: typeof initialState, instance: unknown) => { +const importInstance = (_state: typeof initialState, instance: APIEntity) => { return instanceSchema.parse(instance); }; @@ -45,8 +47,10 @@ const importConfigs = (state: typeof initialState, configs: ImmutableList) const registrationsOpen = getConfigValue(value, ':registrations_open') as boolean | undefined; const approvalRequired = getConfigValue(value, ':account_approval_required') as boolean | undefined; - draft.registrations = registrationsOpen ?? draft.registrations; - draft.approval_required = approvalRequired ?? draft.approval_required; + draft.registrations = { + enabled: registrationsOpen ?? draft.registrations.enabled, + approval_required: approvalRequired ?? draft.registrations.approval_required, + }; } if (simplePolicy) { @@ -76,9 +80,7 @@ const getHost = (instance: { uri: string }) => { } }; -const persistInstance = (instance: { uri: string }) => { - const host = getHost(instance); - +const persistInstance = (instance: { uri: string }, host: string | null = getHost(instance)) => { if (host) { KVStore.setItem(`instance:${host}`, instance).catch(console.error); } @@ -97,11 +99,13 @@ 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, action.payload); + return importInstance(state, action.payload.instance); case fetchInstance.fulfilled.type: + case fetchInstanceV2.fulfilled.type: persistInstance(action.payload); - return importInstance(state, action.payload); + return importInstance(state, action.payload.instance); case fetchInstance.rejected.type: + case fetchInstanceV2.rejected.type: return handleInstanceFetchFail(state, action.error); case ADMIN_CONFIG_UPDATE_REQUEST: case ADMIN_CONFIG_UPDATE_SUCCESS: diff --git a/src/schemas/instance.test.ts b/src/schemas/instance.test.ts new file mode 100644 index 000000000..a47e1adad --- /dev/null +++ b/src/schemas/instance.test.ts @@ -0,0 +1,214 @@ +import { instanceSchema } from './instance'; + +describe('instanceSchema.parse()', () => { + it('normalizes an empty Map', () => { + const expected = { + configuration: { + media_attachments: {}, + chats: { + max_characters: 5000, + max_media_attachments: 1, + }, + groups: { + max_characters_name: 50, + max_characters_description: 160, + }, + polls: { + max_options: 4, + max_characters_per_option: 25, + min_expiration: 300, + max_expiration: 2629746, + }, + statuses: { + max_characters: 500, + max_media_attachments: 4, + }, + translation: { + enabled: false, + }, + urls: {}, + }, + contact: { + email: '', + }, + description: '', + domain: '', + feature_quote: false, + fedibird_capabilities: [], + languages: [], + pleroma: { + metadata: { + account_activation_required: false, + birthday_min_age: 0, + birthday_required: false, + description_limit: 1500, + features: [], + federation: { + enabled: true, + }, + }, + stats: {}, + }, + registrations: { + approval_required: false, + enabled: false, + }, + rules: [], + stats: {}, + title: '', + thumbnail: { + url: '', + }, + usage: { + users: { + active_month: 0, + }, + }, + version: '0.0.0', + }; + + const result = instanceSchema.parse({}); + expect(result).toMatchObject(expected); + }); + + it('normalizes Pleroma instance with Mastodon configuration format', () => { + const instance = require('soapbox/__fixtures__/pleroma-instance.json'); + + const expected = { + configuration: { + statuses: { + max_characters: 5000, + max_media_attachments: Infinity, + }, + polls: { + max_options: 20, + max_characters_per_option: 200, + min_expiration: 0, + max_expiration: 31536000, + }, + }, + }; + + const result = instanceSchema.parse(instance); + expect(result).toMatchObject(expected); + }); + + it('normalizes Mastodon instance with retained configuration', () => { + const instance = require('soapbox/__fixtures__/mastodon-instance.json'); + + const expected = { + configuration: { + statuses: { + max_characters: 500, + max_media_attachments: 4, + characters_reserved_per_url: 23, + }, + media_attachments: { + image_size_limit: 10485760, + image_matrix_limit: 16777216, + video_size_limit: 41943040, + video_frame_rate_limit: 60, + video_matrix_limit: 2304000, + }, + polls: { + max_options: 4, + max_characters_per_option: 50, + min_expiration: 300, + max_expiration: 2629746, + }, + }, + }; + + const result = instanceSchema.parse(instance); + expect(result).toMatchObject(expected); + }); + + it('normalizes Mastodon 3.0.0 instance with default configuration', () => { + const instance = require('soapbox/__fixtures__/mastodon-3.0.0-instance.json'); + + const expected = { + configuration: { + statuses: { + max_characters: 500, + max_media_attachments: 4, + }, + polls: { + max_options: 4, + max_characters_per_option: 25, + min_expiration: 300, + max_expiration: 2629746, + }, + }, + }; + + const result = instanceSchema.parse(instance); + expect(result).toMatchObject(expected); + }); + + it('normalizes Fedibird instance', () => { + const instance = require('soapbox/__fixtures__/fedibird-instance.json'); + const result = instanceSchema.parse(instance); + + // Sets description_limit + expect(result.pleroma.metadata.description_limit).toEqual(1500); + + // Preserves fedibird_capabilities + expect(result.fedibird_capabilities).toEqual(instance.fedibird_capabilities); + }); + + it('normalizes Mitra instance', () => { + const instance = require('soapbox/__fixtures__/mitra-instance.json'); + const result = instanceSchema.parse(instance); + + // Adds configuration and description_limit + expect(result.configuration).toBeTruthy(); + expect(result.pleroma.metadata.description_limit).toBe(1500); + }); + + it('normalizes GoToSocial instance', () => { + const instance = require('soapbox/__fixtures__/gotosocial-instance.json'); + const result = instanceSchema.parse(instance); + + // Normalizes max_toot_chars + expect(result.configuration.statuses.max_characters).toEqual(5000); + expect('max_toot_chars' in result).toBe(false); + + // Adds configuration and description_limit + expect(result.configuration).toBeTruthy(); + expect(result.pleroma.metadata.description_limit).toBe(1500); + }); + + it('normalizes Friendica instance', () => { + const instance = require('soapbox/__fixtures__/friendica-instance.json'); + const result = instanceSchema.parse(instance); + + // Normalizes max_toot_chars + expect(result.configuration.statuses.max_characters).toEqual(200000); + expect('max_toot_chars' in result).toBe(false); + + // Adds configuration and description_limit + expect(result.configuration).toBeTruthy(); + expect(result.pleroma.metadata.description_limit).toBe(1500); + }); + + it('normalizes a Mastodon RC version', () => { + const instance = require('soapbox/__fixtures__/mastodon-instance-rc.json'); + const result = instanceSchema.parse(instance); + + expect(result.version).toEqual('3.5.0-rc1'); + }); + + it('normalizes Pixelfed instance', () => { + const instance = require('soapbox/__fixtures__/pixelfed-instance.json'); + const result = instanceSchema.parse(instance); + expect(result.title).toBe('pixelfed'); + }); + + it('renames Akkoma to Pleroma', () => { + const instance = require('soapbox/__fixtures__/akkoma-instance.json'); + const result = instanceSchema.parse(instance); + + expect(result.version).toEqual('2.7.2 (compatible; Pleroma 2.4.50+akkoma)'); + + }); +}); diff --git a/src/schemas/instance.ts b/src/schemas/instance.ts index ee647c678..818048f04 100644 --- a/src/schemas/instance.ts +++ b/src/schemas/instance.ts @@ -1,11 +1,15 @@ /* eslint sort-keys: "error" */ import z from 'zod'; +import { PLEROMA, parseVersion } from 'soapbox/utils/features'; + import { accountSchema } from './account'; import { mrfSimpleSchema } from './pleroma'; import { ruleSchema } from './rule'; import { coerceObject, filteredArray, mimeSchema } from './utils'; +const getAttachmentLimit = (software: string | null) => software === PLEROMA ? Infinity : 4; + const fixVersion = (version: string) => { // Handle Mastodon release candidates if (new RegExp(/[0-9.]+rc[0-9]+/g).test(version)) { @@ -53,9 +57,22 @@ const configurationSchema = coerceObject({ max_reactions: z.number().catch(0), }), statuses: coerceObject({ + characters_reserved_per_url: z.number().optional().catch(undefined), max_characters: z.number().optional().catch(undefined), max_media_attachments: z.number().optional().catch(undefined), + }), + translation: coerceObject({ + enabled: z.boolean().catch(false), + }), + urls: coerceObject({ + streaming: z.string().url().optional().catch(undefined), + }), +}); + +const contactSchema = coerceObject({ + contact_account: accountSchema.optional().catch(undefined), + email: z.string().email().catch(''), }); const nostrSchema = coerceObject({ @@ -68,6 +85,7 @@ const pleromaSchema = coerceObject({ account_activation_required: z.boolean().catch(false), birthday_min_age: z.number().catch(0), birthday_required: z.boolean().catch(false), + description_limit: z.number().catch(1500), features: z.string().array().catch([]), federation: coerceObject({ enabled: z.boolean().catch(true), // Assume true unless explicitly false @@ -115,14 +133,20 @@ const pleromaPollLimitsSchema = coerceObject({ min_expiration: z.number().optional().catch(undefined), }); -const statsSchema = coerceObject({ - domain_count: z.number().catch(0), - status_count: z.number().catch(0), - user_count: z.number().catch(0), +const registrations = coerceObject({ + approval_required: z.boolean().catch(false), + enabled: z.boolean().catch(false), + message: z.string().optional().catch(undefined), }); -const urlsSchema = coerceObject({ - streaming_api: z.string().url().optional().catch(undefined), +const statsSchema = coerceObject({ + domain_count: z.number().optional().catch(undefined), + status_count: z.number().optional().catch(undefined), + user_count: z.number().optional().catch(undefined), +}); + +const thumbnailSchema = coerceObject({ + url: z.string().catch(''), }); const usageSchema = coerceObject({ @@ -131,7 +155,7 @@ const usageSchema = coerceObject({ }), }); -const instanceSchema = coerceObject({ +const instanceV1Schema = coerceObject({ approval_required: z.boolean().catch(false), configuration: configurationSchema, contact_account: accountSchema.optional().catch(undefined), @@ -152,26 +176,106 @@ const instanceSchema = coerceObject({ stats: statsSchema, thumbnail: z.string().catch(''), title: z.string().catch(''), - urls: urlsSchema, + urls: coerceObject({ + streaming_api: z.string().url().optional().catch(undefined), + }), usage: usageSchema, - version: z.string().catch(''), -}).transform(({ max_media_attachments, max_toot_chars, poll_limits, ...instance }) => { - const { configuration } = instance; + version: z.string().catch('0.0.0'), +}); +const instanceSchema = z.preprocess((data: any) => { + if (data.domain) return data; + + const { + approval_required, + configuration, + contact_account, + description, + description_limit, + email, + max_media_attachments, + max_toot_chars, + poll_limits, + pleroma, + registrations, + short_description, + thumbnail, + urls, + ...instance + } = instanceV1Schema.parse(data); + + const { software } = parseVersion(instance.version); + + return { + ...instance, + configuration: { + ...configuration, + polls: { + ...configuration.polls, + max_characters_per_option: configuration.polls.max_characters_per_option ?? poll_limits.max_option_chars ?? 25, + max_expiration: configuration.polls.max_expiration ?? poll_limits.max_expiration ?? 2629746, + max_options: configuration.polls.max_options ?? poll_limits.max_options ?? 4, + min_expiration: configuration.polls.min_expiration ?? poll_limits.min_expiration ?? 300, + }, + statuses: { + ...configuration.statuses, + max_characters: configuration.statuses.max_characters ?? max_toot_chars ?? 500, + max_media_attachments: configuration.statuses.max_media_attachments ?? max_media_attachments ?? getAttachmentLimit(software), + }, + urls: { + streaming: urls.streaming_api, + }, + }, + contact: { + account: contact_account, + email: email, + }, + description: short_description || description, + pleroma: { + ...pleroma, + metadata: { + ...pleroma.metadata, + description_limit, + }, + }, + registrations: { + approval_required: approval_required, + enabled: registrations, + }, + thumbnail: { url: thumbnail }, + }; +}, coerceObject({ + configuration: configurationSchema, + contact: contactSchema, + description: z.string().catch(''), + domain: z.string().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: registrations, + rules: filteredArray(ruleSchema), + stats: statsSchema, + thumbnail: thumbnailSchema, + title: z.string().catch(''), + usage: usageSchema, + version: z.string().catch('0.0.0'), +}).transform(({ configuration, ...instance }) => { const version = fixVersion(instance.version); const polls = { ...configuration.polls, - max_characters_per_option: configuration.polls.max_characters_per_option ?? poll_limits.max_option_chars ?? 25, - max_expiration: configuration.polls.max_expiration ?? poll_limits.max_expiration ?? 2629746, - max_options: configuration.polls.max_options ?? poll_limits.max_options ?? 4, - min_expiration: configuration.polls.min_expiration ?? poll_limits.min_expiration ?? 300, + max_characters_per_option: configuration.polls.max_characters_per_option ?? 25, + max_expiration: configuration.polls.max_expiration ?? 2629746, + max_options: configuration.polls.max_options ?? 4, + min_expiration: configuration.polls.min_expiration ?? 300, }; const statuses = { ...configuration.statuses, - max_characters: configuration.statuses.max_characters ?? max_toot_chars ?? 500, - max_media_attachments: configuration.statuses.max_media_attachments ?? max_media_attachments ?? 4, + max_characters: configuration.statuses.max_characters ?? 500, + max_media_attachments: configuration.statuses.max_media_attachments ?? 4, }; return { @@ -183,7 +287,7 @@ const instanceSchema = coerceObject({ }, version, }; -}); +})); type Instance = z.infer; diff --git a/src/stream.ts b/src/stream.ts index bcbcd2b02..599704d12 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.streaming_api; + const streamingAPIBaseURL = getState().instance.configuration.urls.streaming; const accessToken = getAccessToken(getState()); const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState); diff --git a/src/types/entities.ts b/src/types/entities.ts index 20eb419af..98d36a07c 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -12,7 +12,6 @@ import { FilterKeywordRecord, FilterStatusRecord, HistoryRecord, - InstanceRecord, ListRecord, LocationRecord, MentionRecord, @@ -41,7 +40,6 @@ type Filter = ReturnType; type FilterKeyword = ReturnType; type FilterStatus = ReturnType; type History = ReturnType; -type Instance = ReturnType; type List = ReturnType; type Location = ReturnType; type Mention = ReturnType; @@ -77,7 +75,6 @@ export { FilterKeyword, FilterStatus, History, - Instance, List, Location, Mention, diff --git a/src/utils/features.ts b/src/utils/features.ts index c004c6a4c..a89a5c86e 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -641,6 +641,16 @@ const getInstanceFeatures = (instance: Instance) => { */ importData: v.software === PLEROMA && gte(v.version, '2.2.0'), + /** + * Mastodon server information API v2. + * @see GET /api/v2/instance + * @see {@link https://docs.joinmastodon.org/methods/instance/#v2} + */ + instanceV2: any([ + v.software === MASTODON && gte(v.compatVersion, '4.0.0'), + v.software === PLEROMA && v.build === REBASED && gte(v.version, '2.5.54'), + ]), + /** * Can create, view, and manage lists. * @see {@link https://docs.joinmastodon.org/methods/lists/} @@ -954,7 +964,7 @@ const getInstanceFeatures = (instance: Instance) => { * Can translate statuses. * @see POST /api/v1/statuses/:id/translate */ - translations: features.includes('translation'), + translations: features.includes('translation') || instance.configuration.translation.enabled, /** * Trending statuses. @@ -1024,7 +1034,7 @@ export const parseVersion = (version: string): Backend => { build: semver.build[0], compatVersion: compat.version, software: match[2] || MASTODON, - version: semver.version, + version: semver.version.split('-')[0], }; } else { // If we can't parse the version, this is a new and exotic backend.