From 59115c8dc5a5107d6d55e5860b60e8d553d347ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 27 Oct 2023 17:48:21 +0200 Subject: [PATCH 1/9] Remove instance normalizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/api/hooks/groups/useGroups.test.ts | 4 +- src/api/hooks/groups/usePendingGroups.test.ts | 4 +- .../auth-login/components/login-form.test.tsx | 6 +- .../auth-login/components/login-page.test.tsx | 4 +- .../discover/search/search.test.tsx | 4 +- src/features/groups/discover.test.tsx | 4 +- src/hooks/useGroupsPath.test.ts | 6 +- src/jest/mock-stores.tsx | 6 +- src/normalizers/index.ts | 1 - src/normalizers/instance.test.ts | 214 ------------------ src/normalizers/instance.ts | 164 -------------- src/types/entities.ts | 3 - 12 files changed, 19 insertions(+), 401 deletions(-) delete mode 100644 src/normalizers/instance.test.ts delete mode 100644 src/normalizers/instance.ts 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/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/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/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/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/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, From 7c752f088c15702c24b7526806f96e86f1344838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 27 Oct 2023 21:18:30 +0200 Subject: [PATCH 2/9] instance v1 to v2 convesion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/actions/instance.ts | 47 ++++++-- src/api/hooks/streaming/useTimelineStream.ts | 2 +- .../components/registration-mode-picker.tsx | 6 +- .../components/registration-form.tsx | 2 +- src/features/compose/components/upload.tsx | 2 +- .../components/site-banner.tsx | 2 +- src/reducers/instance.ts | 24 ++-- src/schemas/instance.ts | 104 ++++++++++++++++-- src/stream.ts | 2 +- src/utils/features.ts | 14 ++- 10 files changed, 165 insertions(+), 40 deletions(-) diff --git a/src/actions/instance.ts b/src/actions/instance.ts index 6ea62ca96..b949dcb65 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,11 @@ 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 dispatch(fetchInstance(host)); }, ); 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/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/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/reducers/instance.ts b/src/reducers/instance.ts index 2ccb89325..1f98e097d 100644 --- a/src/reducers/instance.ts +++ b/src/reducers/instance.ts @@ -4,20 +4,24 @@ 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 { type Instance, instanceSchema } from 'soapbox/schemas'; +import { instanceV1ToV2 } from 'soapbox/schemas/instance'; import KVStore from 'soapbox/storage/kv-store'; 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) => { - return instanceSchema.parse(instance); +const importInstance = (_state: typeof initialState, instance: APIEntity) => { + if (typeof instance.domain === 'string') return instanceSchema.parse(instance); + return instanceV1ToV2.parse(instance); }; const preloadImport = (state: typeof initialState, action: Record, path: string) => { @@ -45,8 +49,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 +82,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 +101,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.ts b/src/schemas/instance.ts index 4e55a7456..e1c1ef0a8 100644 --- a/src/schemas/instance.ts +++ b/src/schemas/instance.ts @@ -53,6 +53,17 @@ const configurationSchema = coerceObject({ 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({ @@ -65,6 +76,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 @@ -112,14 +124,20 @@ const pleromaPollLimitsSchema = coerceObject({ min_expiration: z.number().optional().catch(undefined), }); +const registrations = coerceObject({ + approval_required: z.boolean().catch(false), + enabled: z.boolean().catch(false), + message: z.string().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 urlsSchema = coerceObject({ - streaming_api: z.string().url().optional().catch(undefined), +const thumbnailSchema = coerceObject({ + url: z.string().catch(''), }); const usageSchema = coerceObject({ @@ -129,12 +147,10 @@ const usageSchema = coerceObject({ }); const instanceSchema = coerceObject({ - approval_required: z.boolean().catch(false), configuration: configurationSchema, - contact_account: accountSchema.optional().catch(undefined), + contact: contactSchema, description: z.string().catch(''), - description_limit: z.number().catch(1500), - email: z.string().email().catch(''), + domain: z.string().catch(''), feature_quote: z.boolean().catch(false), fedibird_capabilities: z.array(z.string()).catch([]), languages: z.string().array().catch([]), @@ -143,13 +159,11 @@ const instanceSchema = coerceObject({ nostr: nostrSchema.optional().catch(undefined), pleroma: pleromaSchema, poll_limits: pleromaPollLimitsSchema, - registrations: z.boolean().catch(false), + registrations: registrations, rules: filteredArray(ruleSchema), - short_description: z.string().catch(''), stats: statsSchema, - thumbnail: z.string().catch(''), + thumbnail: thumbnailSchema, title: z.string().catch(''), - urls: urlsSchema, usage: usageSchema, version: z.string().catch(''), }).transform(({ max_media_attachments, max_toot_chars, poll_limits, ...instance }) => { @@ -182,6 +196,74 @@ const instanceSchema = coerceObject({ }; }); +const instanceV1ToV2 = 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([]), + max_media_attachments: z.number().optional().catch(undefined), + max_toot_chars: z.number().optional().catch(undefined), + nostr: nostrSchema.optional().catch(undefined), + pleroma: pleromaSchema, + poll_limits: pleromaPollLimitsSchema, + registrations: z.boolean().catch(false), + rules: filteredArray(ruleSchema), + short_description: z.string().catch(''), + stats: statsSchema, + thumbnail: z.string().catch(''), + title: z.string().catch(''), + urls: coerceObject({ + streaming_api: z.string().url().optional().catch(undefined), + }), + usage: usageSchema, + version: z.string().catch(''), +}).transform(({ + approval_required, + configuration, + contact_account, + description, + description_limit, + email, + pleroma, + registrations, + short_description, + thumbnail, + urls, + ...instance +}) => { + return instanceSchema.parse({ + ...instance, + configuration: { + ...configuration, + 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 }, + }); +}); + type Instance = z.infer; -export { instanceSchema, Instance }; +export { instanceSchema, instanceV1ToV2, Instance }; 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/utils/features.ts b/src/utils/features.ts index b7dadd51b..842e6490f 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -620,6 +620,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/} @@ -926,7 +936,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. @@ -994,7 +1004,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. From d462e6df8f5932577cc09c4a7b26f2ee2789d935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 28 Oct 2023 12:13:31 +0200 Subject: [PATCH 3/9] Add missing `await` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/actions/instance.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/actions/instance.ts b/src/actions/instance.ts index b949dcb65..c6ccd053b 100644 --- a/src/actions/instance.ts +++ b/src/actions/instance.ts @@ -82,7 +82,9 @@ export const loadInstance = createAsyncThunk( if (rememberedInstance.payload && supportsInstanceV2((rememberedInstance.payload as any).instance)) { await dispatch(fetchInstanceV2(host)); - } else dispatch(fetchInstance(host)); + } else { + await dispatch(fetchInstance(host)); + } }, ); From 1b31a0558e3375bac4ab9b62d30c57e0f039b16f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 28 Oct 2023 16:20:54 +0200 Subject: [PATCH 4/9] Fix registration status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/features/ui/index.tsx | 2 +- src/hooks/useRegistrationStatus.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index 59857d52f..dbf21c6f0 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/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 From b5668f1f70f78f070de82694879d81aacf6ae708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 28 Oct 2023 16:28:53 +0200 Subject: [PATCH 5/9] Remove `poll_limits` as they're not part of instancev2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/schemas/instance.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/schemas/instance.ts b/src/schemas/instance.ts index e1c1ef0a8..29bdcb945 100644 --- a/src/schemas/instance.ts +++ b/src/schemas/instance.ts @@ -158,7 +158,6 @@ const instanceSchema = coerceObject({ max_toot_chars: z.number().optional().catch(undefined), nostr: nostrSchema.optional().catch(undefined), pleroma: pleromaSchema, - poll_limits: pleromaPollLimitsSchema, registrations: registrations, rules: filteredArray(ruleSchema), stats: statsSchema, @@ -229,6 +228,7 @@ const instanceV1ToV2 = coerceObject({ description, description_limit, email, + poll_limits, pleroma, registrations, short_description, @@ -240,6 +240,12 @@ const instanceV1ToV2 = coerceObject({ ...instance, configuration: { ...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, + }, urls: { streaming: urls.streaming_api, }, From 54e1b3d4e87fc7e63f787ef283b76316aa557ec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 28 Oct 2023 16:29:29 +0200 Subject: [PATCH 6/9] Remove `poll_limits` as they're not part of instancev2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/schemas/instance.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/schemas/instance.ts b/src/schemas/instance.ts index 29bdcb945..12faca627 100644 --- a/src/schemas/instance.ts +++ b/src/schemas/instance.ts @@ -165,19 +165,11 @@ const instanceSchema = coerceObject({ title: z.string().catch(''), usage: usageSchema, version: z.string().catch(''), -}).transform(({ max_media_attachments, max_toot_chars, poll_limits, ...instance }) => { +}).transform(({ max_media_attachments, max_toot_chars, ...instance }) => { const { 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, - }; - const statuses = { ...configuration.statuses, max_characters: configuration.statuses.max_characters ?? max_toot_chars ?? 500, @@ -188,7 +180,6 @@ const instanceSchema = coerceObject({ ...instance, configuration: { ...configuration, - polls, statuses, }, version, From 144e13e674af16480a87d399dfe9784c23839e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 28 Oct 2023 16:36:44 +0200 Subject: [PATCH 7/9] Remove params not being part of instancev2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/schemas/instance.ts | 86 +++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/src/schemas/instance.ts b/src/schemas/instance.ts index 12faca627..9e51feaea 100644 --- a/src/schemas/instance.ts +++ b/src/schemas/instance.ts @@ -154,8 +154,6 @@ const instanceSchema = coerceObject({ feature_quote: z.boolean().catch(false), fedibird_capabilities: z.array(z.string()).catch([]), languages: z.string().array().catch([]), - max_media_attachments: z.number().optional().catch(undefined), - max_toot_chars: z.number().optional().catch(undefined), nostr: nostrSchema.optional().catch(undefined), pleroma: pleromaSchema, registrations: registrations, @@ -165,21 +163,28 @@ const instanceSchema = coerceObject({ title: z.string().catch(''), usage: usageSchema, version: z.string().catch(''), -}).transform(({ max_media_attachments, max_toot_chars, ...instance }) => { - const { configuration } = instance; - +}).transform(({ configuration, ...instance }) => { const version = fixVersion(instance.version); + const polls = { + ...configuration.polls, + 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 { ...instance, configuration: { ...configuration, + polls, statuses, }, version, @@ -219,6 +224,8 @@ const instanceV1ToV2 = coerceObject({ description, description_limit, email, + max_media_attachments, + max_toot_chars, poll_limits, pleroma, registrations, @@ -226,40 +233,45 @@ const instanceV1ToV2 = coerceObject({ thumbnail, urls, ...instance -}) => { - return instanceSchema.parse({ - ...instance, - configuration: { - ...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, - }, - urls: { - streaming: urls.streaming_api, - }, +}) => instanceSchema.parse({ + ...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, }, - contact: { - account: contact_account, - email: email, + 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, }, - description: short_description || description, - pleroma: { - ...pleroma, - metadata: { - ...pleroma.metadata, - description_limit, - }, + urls: { + streaming: urls.streaming_api, }, - registrations: { - approval_required: approval_required, - enabled: registrations, + }, + contact: { + account: contact_account, + email: email, + }, + description: short_description || description, + pleroma: { + ...pleroma, + metadata: { + ...pleroma.metadata, + description_limit, }, - thumbnail: { url: thumbnail }, - }); -}); + }, + registrations: { + approval_required: approval_required, + enabled: registrations, + }, + thumbnail: { url: thumbnail }, +}), +); type Instance = z.infer; From 4a6a76ddd9777be50a782dcbe8a317c98ea1f2b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 28 Oct 2023 20:51:55 +0200 Subject: [PATCH 8/9] Use preprocess for instance v1 to v2 conversion, add test for instance schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/reducers/instance.ts | 4 +- src/schemas/instance.test.ts | 214 +++++++++++++++++++++++++++++++++++ src/schemas/instance.ts | 191 ++++++++++++++++--------------- 3 files changed, 317 insertions(+), 92 deletions(-) create mode 100644 src/schemas/instance.test.ts diff --git a/src/reducers/instance.ts b/src/reducers/instance.ts index 1f98e097d..a6524207e 100644 --- a/src/reducers/instance.ts +++ b/src/reducers/instance.ts @@ -4,7 +4,6 @@ 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 { type Instance, instanceSchema } from 'soapbox/schemas'; -import { instanceV1ToV2 } from 'soapbox/schemas/instance'; import KVStore from 'soapbox/storage/kv-store'; import { ConfigDB } from 'soapbox/utils/config-db'; @@ -20,8 +19,7 @@ import type { APIEntity } from 'soapbox/types/entities'; const initialState: Instance = instanceSchema.parse({}); const importInstance = (_state: typeof initialState, instance: APIEntity) => { - if (typeof instance.domain === 'string') return instanceSchema.parse(instance); - return instanceV1ToV2.parse(instance); + return instanceSchema.parse(instance); }; const preloadImport = (state: typeof initialState, action: Record, path: string) => { 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 9e51feaea..25c640c6a 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)) { @@ -50,8 +54,10 @@ const configurationSchema = coerceObject({ min_expiration: z.number().optional().catch(undefined), }), 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), @@ -131,9 +137,9 @@ const registrations = coerceObject({ }); const statsSchema = coerceObject({ - domain_count: z.number().catch(0), - status_count: z.number().catch(0), - user_count: z.number().catch(0), + domain_count: z.number().optional().catch(undefined), + status_count: z.number().optional().catch(undefined), + user_count: z.number().optional().catch(undefined), }); const thumbnailSchema = coerceObject({ @@ -146,7 +152,96 @@ const usageSchema = coerceObject({ }), }); -const instanceSchema = coerceObject({ +const instanceV1Schema = 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([]), + max_media_attachments: z.number().optional().catch(undefined), + max_toot_chars: z.number().optional().catch(undefined), + nostr: nostrSchema.optional().catch(undefined), + pleroma: pleromaSchema, + poll_limits: pleromaPollLimitsSchema, + registrations: z.boolean().catch(false), + rules: filteredArray(ruleSchema), + short_description: z.string().catch(''), + stats: statsSchema, + thumbnail: z.string().catch(''), + title: z.string().catch(''), + urls: coerceObject({ + streaming_api: z.string().url().optional().catch(undefined), + }), + usage: usageSchema, + 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(''), @@ -162,7 +257,7 @@ const instanceSchema = coerceObject({ thumbnail: thumbnailSchema, title: z.string().catch(''), usage: usageSchema, - version: z.string().catch(''), + version: z.string().catch('0.0.0'), }).transform(({ configuration, ...instance }) => { const version = fixVersion(instance.version); @@ -189,90 +284,8 @@ const instanceSchema = coerceObject({ }, version, }; -}); - -const instanceV1ToV2 = 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([]), - max_media_attachments: z.number().optional().catch(undefined), - max_toot_chars: z.number().optional().catch(undefined), - nostr: nostrSchema.optional().catch(undefined), - pleroma: pleromaSchema, - poll_limits: pleromaPollLimitsSchema, - registrations: z.boolean().catch(false), - rules: filteredArray(ruleSchema), - short_description: z.string().catch(''), - stats: statsSchema, - thumbnail: z.string().catch(''), - title: z.string().catch(''), - urls: coerceObject({ - streaming_api: z.string().url().optional().catch(undefined), - }), - usage: usageSchema, - version: z.string().catch(''), -}).transform(({ - approval_required, - configuration, - contact_account, - description, - description_limit, - email, - max_media_attachments, - max_toot_chars, - poll_limits, - pleroma, - registrations, - short_description, - thumbnail, - urls, - ...instance -}) => instanceSchema.parse({ - ...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 ?? 4, - }, - 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 }, -}), -); +})); type Instance = z.infer; -export { instanceSchema, instanceV1ToV2, Instance }; +export { instanceSchema, Instance }; From 19dec9c90edbcfb1671524fe7419657cdaff45e2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 23 Dec 2023 20:30:56 +0000 Subject: [PATCH 9/9] Apply 1 suggestion(s) to 1 file(s) --- src/schemas/instance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schemas/instance.ts b/src/schemas/instance.ts index 25c640c6a..32afa4490 100644 --- a/src/schemas/instance.ts +++ b/src/schemas/instance.ts @@ -180,7 +180,7 @@ const instanceV1Schema = coerceObject({ version: z.string().catch('0.0.0'), }); -const instanceSchema = z.preprocess((data: any) => { +const instanceSchema = z.preprocess((data: unknown) => { if (data.domain) return data; const {