diff --git a/app/soapbox/__fixtures__/mastodon-instance-v2.json b/app/soapbox/__fixtures__/mastodon-instance-v2.json new file mode 100644 index 000000000..08327f8cb --- /dev/null +++ b/app/soapbox/__fixtures__/mastodon-instance-v2.json @@ -0,0 +1,128 @@ +{ + "domain": "mastodon.social", + "title": "Mastodon", + "version": "4.0.2", + "source_url": "https://github.com/mastodon/mastodon", + "description": "The original server operated by the Mastodon gGmbH non-profit", + "usage": { "users": { "active_month": 227900 } }, + "thumbnail": { + "url": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png", + "blurhash": "UeKUpFxuo~R%0nW;WCnhF6RjaJt757oJodS$", + "versions": { + "@1x": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png", + "@2x": "https://files.mastodon.social/site_uploads/files/000/000/001/@2x/57c12f441d083cde.png" + } + }, + "languages": ["en"], + "configuration": { + "urls": { "streaming": "wss://mastodon.social" }, + "accounts": { "max_featured_tags": 10 }, + "statuses": { + "max_characters": 500, + "max_media_attachments": 4, + "characters_reserved_per_url": 23 + }, + "media_attachments": { + "supported_mime_types": [ + "image/jpeg", + "image/png", + "image/gif", + "image/heic", + "image/heif", + "image/webp", + "image/avif", + "video/webm", + "video/mp4", + "video/quicktime", + "video/ogg", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wave", + "audio/vnd.wave", + "audio/ogg", + "audio/vorbis", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/flac", + "audio/aac", + "audio/m4a", + "audio/x-m4a", + "audio/mp4", + "audio/3gpp", + "video/x-ms-asf" + ], + "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 + }, + "translation": { "enabled": true } + }, + "registrations": { + "enabled": false, + "approval_required": false, + "message": null + }, + "contact": { + "email": "staff@mastodon.social", + "account": { + "id": "1", + "username": "Gargron", + "acct": "Gargron", + "display_name": "Eugen Rochko", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2016-03-16T00:00:00.000Z", + "note": "\u003cp\u003eFounder, CEO and lead developer \u003cspan class=\"h-card\"\u003e\u003ca href=\"https://mastodon.social/@Mastodon\" class=\"u-url mention\"\u003e@\u003cspan\u003eMastodon\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e, Germany.\u003c/p\u003e", + "url": "https://mastodon.social/@Gargron", + "avatar": "https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg", + "avatar_static": "https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg", + "header": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", + "header_static": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", + "followers_count": 263431, + "following_count": 327, + "statuses_count": 72827, + "last_status_at": "2022-12-03", + "noindex": false, + "emojis": [], + "fields": [ + { + "name": "Patreon", + "value": "\u003ca href=\"https://www.patreon.com/mastodon\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"\u003e\u003cspan class=\"invisible\"\u003ehttps://www.\u003c/span\u003e\u003cspan class=\"\"\u003epatreon.com/mastodon\u003c/span\u003e\u003cspan class=\"invisible\"\u003e\u003c/span\u003e\u003c/a\u003e", + "verified_at": null + } + ] + } + }, + "rules": [ + { + "id": "1", + "text": "Sexually explicit or violent media must be marked as sensitive when posting" + }, + { + "id": "2", + "text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism" + }, + { + "id": "3", + "text": "No incitement of violence or promotion of violent ideologies" + }, + { "id": "4", "text": "No harassment, dogpiling or doxxing of other users" }, + { "id": "5", "text": "No content illegal in Germany" }, + { + "id": "7", + "text": "Do not share intentionally false or misleading information" + } + ] +} diff --git a/app/soapbox/actions/external-auth.ts b/app/soapbox/actions/external-auth.ts index 064e100c9..5e1e72de7 100644 --- a/app/soapbox/actions/external-auth.ts +++ b/app/soapbox/actions/external-auth.ts @@ -77,7 +77,7 @@ const externalAuthorize = (instance: Instance, baseURL: string) => const externalEthereumLogin = (instance: Instance, baseURL?: string) => (dispatch: AppDispatch) => { - const loginMessage = instance.login_message; + const loginMessage = instance.registrations.get('message'); return getWalletAndSign(loginMessage).then(({ wallet, signature }) => { return dispatch(createExternalApp(instance, baseURL)).then((app) => { diff --git a/app/soapbox/actions/instance.ts b/app/soapbox/actions/instance.ts index ca1fc3ef5..5c684273f 100644 --- a/app/soapbox/actions/instance.ts +++ b/app/soapbox/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 } from 'soapbox/utils/auth'; -import { parseVersion } from 'soapbox/utils/features'; +import { MASTODON, parseVersion, PLEROMA, REBASED } from 'soapbox/utils/features'; import api from '../api'; @@ -27,25 +28,48 @@ 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.0')); +}; + /** 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); } @@ -57,10 +81,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/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index 74f53967d..e3a36d65b 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -143,7 +143,7 @@ const SoapboxMount = () => { - {(features.accountCreation && instance.registrations) && ( + {(features.accountCreation && instance.registrations.get('enabled')) && ( )} diff --git a/app/soapbox/features/admin/components/registration-mode-picker.tsx b/app/soapbox/features/admin/components/registration-mode-picker.tsx index 2c830153a..580af1a17 100644 --- a/app/soapbox/features/admin/components/registration-mode-picker.tsx +++ b/app/soapbox/features/admin/components/registration-mode-picker.tsx @@ -34,8 +34,8 @@ const generateConfig = (mode: RegistrationMode) => { }; const modeFromInstance = (instance: Instance): RegistrationMode => { - if (instance.approval_required && instance.registrations) return 'approval'; - return instance.registrations ? 'open' : 'closed'; + if (instance.registrations.get('approval_required') && instance.registrations.get('enabled')) return 'approval'; + return instance.registrations.get('enabled') ? 'open' : 'closed'; }; /** Allows changing the registration mode of the instance, eg "open", "closed", "approval" */ diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx index 0ffa28217..d42b527f4 100644 --- a/app/soapbox/features/ads/components/ad.tsx +++ b/app/soapbox/features/ads/components/ad.tsx @@ -74,7 +74,7 @@ const Ad: React.FC = ({ ad }) => { - + diff --git a/app/soapbox/features/auth-layout/index.tsx b/app/soapbox/features/auth-layout/index.tsx index 629bb3c9b..939dc752e 100644 --- a/app/soapbox/features/auth-layout/index.tsx +++ b/app/soapbox/features/auth-layout/index.tsx @@ -32,7 +32,7 @@ const AuthLayout = () => { const soapboxConfig = useSoapboxConfig(); const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true; - const isOpen = features.accountCreation && instance.registrations; + const isOpen = features.accountCreation && instance.registrations.get('enabled'); const pepeOpen = useAppSelector(state => state.verification.instance.get('registrations') === true); const isLoginPage = history.location.pathname === '/login'; const shouldShowRegisterLink = (isLoginPage && (isOpen || (pepeEnabled && pepeOpen))); diff --git a/app/soapbox/features/auth-login/components/registration-form.tsx b/app/soapbox/features/auth-login/components/registration-form.tsx index 0f825ea8c..6813b9857 100644 --- a/app/soapbox/features/auth-login/components/registration-form.tsx +++ b/app/soapbox/features/auth-login/components/registration-form.tsx @@ -46,7 +46,7 @@ const RegistrationForm: React.FC = ({ inviteToken }) => { const locale = settings.get('locale'); const needsConfirmation = !!instance.pleroma.getIn(['metadata', 'account_activation_required']); - const needsApproval = instance.approval_required; + const needsApproval = instance.registrations.get('approval_required'); const supportsEmailList = features.emailList; const supportsAccountLookup = features.accountLookup; const birthdayRequired = instance.pleroma.getIn(['metadata', 'birthday_required']); diff --git a/app/soapbox/features/compose/components/upload.tsx b/app/soapbox/features/compose/components/upload.tsx index 030e00fbe..5c56df759 100644 --- a/app/soapbox/features/compose/components/upload.tsx +++ b/app/soapbox/features/compose/components/upload.tsx @@ -70,7 +70,9 @@ const Upload: React.FC = ({ composeId, id }) => { const intl = useIntl(); const history = useHistory(); const dispatch = useAppDispatch(); - const { description_limit: descriptionLimit } = useInstance(); + const { pleroma } = useInstance(); + + const descriptionLimit = pleroma.getIn(['metadata', 'description_limit']) as number; const media = useCompose(composeId).media_attachments.find(item => item.id === id)!; diff --git a/app/soapbox/features/landing-page/__tests__/landing-page.test.tsx b/app/soapbox/features/landing-page/__tests__/landing-page.test.tsx index 6e96672ba..706e4e08e 100644 --- a/app/soapbox/features/landing-page/__tests__/landing-page.test.tsx +++ b/app/soapbox/features/landing-page/__tests__/landing-page.test.tsx @@ -12,8 +12,10 @@ describe('', () => { const state = rootReducer(undefined, { type: rememberInstance.fulfilled.type, payload: { - version: '2.7.2 (compatible; Pleroma 2.3.0)', - registrations: true, + instance: { + version: '2.7.2 (compatible; Pleroma 2.3.0)', + registrations: true, + }, }, }); @@ -29,8 +31,10 @@ describe('', () => { const state = rootReducer(undefined, { type: rememberInstance.fulfilled.type, payload: { - version: '2.7.2 (compatible; Pleroma 2.3.0)', - registrations: false, + instance: { + version: '2.7.2 (compatible; Pleroma 2.3.0)', + registrations: false, + }, }, }); @@ -71,8 +75,10 @@ describe('', () => { const state = applyActions(undefined, [{ type: rememberInstance.fulfilled.type, payload: { - version: '3.4.1 (compatible; TruthSocial 1.0.0)', - registrations: false, + instance: { + version: '3.4.1 (compatible; TruthSocial 1.0.0)', + registrations: false, + }, }, }, { type: PEPE_FETCH_INSTANCE_SUCCESS, diff --git a/app/soapbox/features/landing-page/index.tsx b/app/soapbox/features/landing-page/index.tsx index b7a0d418c..efbef6160 100644 --- a/app/soapbox/features/landing-page/index.tsx +++ b/app/soapbox/features/landing-page/index.tsx @@ -89,13 +89,15 @@ const LandingPage = () => { ); }; + console.log(instance.registrations.toJS()); + // Render registration flow depending on features const renderBody = () => { if (soapboxConfig.authProvider) { return renderProvider(); } else if (pepeEnabled && pepeOpen) { return renderPepe(); - } else if (features.accountCreation && instance.registrations) { + } else if (features.accountCreation && instance.registrations.get('enabled')) { return renderOpen(); } else { return renderClosed(); @@ -115,7 +117,7 @@ const LandingPage = () => { diff --git a/app/soapbox/features/public-layout/components/header.tsx b/app/soapbox/features/public-layout/components/header.tsx index 4fef3df36..52593777d 100644 --- a/app/soapbox/features/public-layout/components/header.tsx +++ b/app/soapbox/features/public-layout/components/header.tsx @@ -35,7 +35,7 @@ const Header = () => { const features = useFeatures(); const instance = useInstance(); - const isOpen = features.accountCreation && instance.registrations; + const isOpen = features.accountCreation && instance.registrations.get('enabled'); const pepeOpen = useAppSelector(state => state.verification.instance.get('registrations') === true); const [isLoading, setLoading] = React.useState(false); diff --git a/app/soapbox/features/ui/components/modals/landing-page-modal.tsx b/app/soapbox/features/ui/components/modals/landing-page-modal.tsx index ba65007db..f852beff3 100644 --- a/app/soapbox/features/ui/components/modals/landing-page-modal.tsx +++ b/app/soapbox/features/ui/components/modals/landing-page-modal.tsx @@ -28,7 +28,7 @@ const LandingPageModal: React.FC = ({ onClose }) => { const instance = useInstance(); const features = useFeatures(); - const isOpen = features.accountCreation && instance.registrations; + const isOpen = features.accountCreation && instance.registrations.get('enabled'); const pepeOpen = useAppSelector(state => state.verification.instance.get('registrations') === true); return ( diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 9c1b5c545..4de961b0a 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -335,7 +335,7 @@ const UI: React.FC = ({ children }) => { const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.openId !== null); const accessToken = useAppSelector(state => getAccessToken(state)); - const streamingUrl = instance.urls.get('streaming_api'); + const streamingUrl = instance.configuration.getIn(['urls', 'streaming_api']); const standalone = useAppSelector(isStandalone); const handleDragEnter = (e: DragEvent) => { diff --git a/app/soapbox/normalizers/__tests__/instance.test.ts b/app/soapbox/normalizers/__tests__/instance.test.ts index 87088b30d..67f62f124 100644 --- a/app/soapbox/normalizers/__tests__/instance.test.ts +++ b/app/soapbox/normalizers/__tests__/instance.test.ts @@ -5,8 +5,6 @@ import { normalizeInstance } from '../instance'; describe('normalizeInstance()', () => { it('normalizes an empty Map', () => { const expected = { - approval_required: false, - contact_account: {}, configuration: { media_attachments: {}, polls: { @@ -19,20 +17,29 @@ describe('normalizeInstance()', () => { max_characters: 500, max_media_attachments: 4, }, + translation: { + enabled: false, + }, + urls: { + streaming: '', + }, + }, + contact: { + account: {}, + email: '', }, description: '', - description_limit: 1500, + domain: '', 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, + description_limit: 1500, features: [], federation: { enabled: true, @@ -41,18 +48,23 @@ describe('normalizeInstance()', () => { }, stats: {}, }, - registrations: false, - rules: [], - short_description: '', - stats: { - domain_count: 0, - status_count: 0, - user_count: 0, + registrations: { + approval_required: false, + enabled: false, + message: '', }, + rules: [], + source_url: '', + stats: {}, title: '', - thumbnail: '', - uri: '', - urls: {}, + thumbnail: { + url: '', + }, + usage: { + users: { + active_month: 0, + }, + }, version: '0.0.0', }; @@ -139,7 +151,7 @@ describe('normalizeInstance()', () => { const result = normalizeInstance(instance); // Sets description_limit - expect(result.description_limit).toEqual(1500); + expect(result.pleroma.getIn(['metadata', 'description_limit'])).toEqual(1500); // Preserves fedibird_capabilities expect(result.fedibird_capabilities).toEqual(fromJS(instance.fedibird_capabilities)); @@ -151,7 +163,7 @@ describe('normalizeInstance()', () => { // Adds configuration and description_limit expect(result.get('configuration') instanceof ImmutableMap).toBe(true); - expect(result.get('description_limit')).toBe(1500); + expect(result.pleroma.getIn(['metadata', 'description_limit'])).toBe(1500); }); it('normalizes GoToSocial instance', () => { @@ -164,7 +176,7 @@ describe('normalizeInstance()', () => { // Adds configuration and description_limit expect(result.get('configuration') instanceof ImmutableMap).toBe(true); - expect(result.get('description_limit')).toBe(1500); + expect(result.pleroma.getIn(['metadata', 'description_limit'])).toBe(1500); }); it('normalizes Friendica instance', () => { @@ -177,7 +189,7 @@ describe('normalizeInstance()', () => { // Adds configuration and description_limit expect(result.get('configuration') instanceof ImmutableMap).toBe(true); - expect(result.get('description_limit')).toBe(1500); + expect(result.pleroma.getIn(['metadata', 'description_limit'])).toBe(1500); }); it('normalizes a Mastodon RC version', () => { diff --git a/app/soapbox/normalizers/instance.ts b/app/soapbox/normalizers/instance.ts index 7a33343f7..4071a97c0 100644 --- a/app/soapbox/normalizers/instance.ts +++ b/app/soapbox/normalizers/instance.ts @@ -17,8 +17,10 @@ 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(), + contact: ImmutableMap({ + account: ImmutableMap(), + email: '', + }), configuration: ImmutableMap({ media_attachments: ImmutableMap(), polls: ImmutableMap({ @@ -31,15 +33,31 @@ export const InstanceRecord = ImmutableRecord({ max_characters: 500, max_media_attachments: 4, }), + translation: ImmutableMap({ enabled: false }), + urls: ImmutableMap(), }), description: '', - description_limit: 1500, + domain: '', email: '', - feature_quote: false, - fedibird_capabilities: ImmutableList(), - invites_enabled: false, languages: ImmutableList(), - login_message: '', + registrations: ImmutableMap({ + approval_required: false, + enabled: false, + message: '', + + }), + rules: ImmutableList(), + source_url: '', + stats: ImmutableMap(), + title: '', + thumbnail: ImmutableMap(), + usage: ImmutableMap({ + users: ImmutableMap({ + active_month: 0, + }), + }), + version: '0.0.0', + pleroma: ImmutableMap({ metadata: ImmutableMap({ account_activation_required: false, @@ -50,22 +68,13 @@ export const InstanceRecord = ImmutableRecord({ enabled: true, exclusions: false, }), + description_limit: 1500, }), stats: ImmutableMap(), }), - registrations: false, - rules: ImmutableList(), - short_description: '', - stats: ImmutableMap({ - domain_count: 0, - status_count: 0, - user_count: 0, - }), - title: '', - thumbnail: '', - uri: '', - urls: ImmutableMap(), - version: '0.0.0', + + feature_quote: false, + fedibird_capabilities: ImmutableList(), }); // Build Mastodon configuration from Pleroma instance @@ -109,10 +118,39 @@ const fixAkkoma = (instance: ImmutableMap) => { } }; +const fixInstanceV1 = (instance: ImmutableMap) => { + instance.setIn(['configuration', 'urls', 'streaming'], instance.getIn(['urls', 'streaming_api'], '')); + + instance.set('contact', ImmutableMap({ + account: instance.get('contact_account'), + email: instance.get('email'), + })); + + instance.set('description', instance.get('short_description') || instance.get('description')); + + instance.set('registrations', ImmutableMap({ + approval_required: instance.get('approval_required'), + enabled: instance.get('registrations'), + message: instance.get('login_message'), + })); + + instance.set('thumbnail', ImmutableMap({ + url: instance.get('thumbnail', ''), + })); + + if (instance.has('pleroma')) { + instance.setIn(['pleroma', 'metadata', 'description_limit'], instance.get('description_limit')); + } +}; + // Normalize instance (Pleroma, Mastodon, etc.) to Mastodon's format export const normalizeInstance = (instance: Record) => { return InstanceRecord( ImmutableMap(fromJS(instance)).withMutations((instance: ImmutableMap) => { + const version = instance.has('domain') ? 'v2' : 'v1'; + + if (version === 'v1') fixInstanceV1(instance); + const { software } = parseVersion(instance.get('version')); const mastodonConfig = pleromaToMastodonConfig(instance); diff --git a/app/soapbox/reducers/__tests__/instance.test.ts b/app/soapbox/reducers/__tests__/instance.test.ts index 8357d6851..04715e35b 100644 --- a/app/soapbox/reducers/__tests__/instance.test.ts +++ b/app/soapbox/reducers/__tests__/instance.test.ts @@ -10,7 +10,6 @@ describe('instance reducer', () => { const result = reducer(undefined, {} as any); const expected = { - description_limit: 1500, configuration: { statuses: { max_characters: 500, @@ -34,7 +33,7 @@ describe('instance reducer', () => { it('normalizes Pleroma instance with Mastodon configuration format', () => { const action = { type: rememberInstance.fulfilled.type, - payload: require('soapbox/__fixtures__/pleroma-instance.json'), + payload: { instance: require('soapbox/__fixtures__/pleroma-instance.json') }, }; const result = reducer(undefined, action); @@ -60,7 +59,7 @@ describe('instance reducer', () => { it('normalizes Mastodon instance with retained configuration', () => { const action = { type: rememberInstance.fulfilled.type, - payload: require('soapbox/__fixtures__/mastodon-instance.json'), + payload: { instance: require('soapbox/__fixtures__/mastodon-instance.json') }, }; const result = reducer(undefined, action); @@ -94,7 +93,7 @@ describe('instance reducer', () => { it('normalizes Mastodon 3.0.0 instance with default configuration', () => { const action = { type: rememberInstance.fulfilled.type, - payload: require('soapbox/__fixtures__/mastodon-3.0.0-instance.json'), + payload: { instance: require('soapbox/__fixtures__/mastodon-3.0.0-instance.json') }, }; const result = reducer(undefined, action); @@ -116,6 +115,40 @@ describe('instance reducer', () => { expect(result.toJS()).toMatchObject(expected); }); + + it('normalizes Mastodon 4.0.2 instance fetched with v2 endpoint', () => { + const action = { + type: rememberInstance.fulfilled.type, + payload: { instance: require('soapbox/__fixtures__/mastodon-instance-v2.json') }, + }; + + const result = reducer(undefined, action); + + const expected = { + configuration: { + statuses: { + max_characters: 500, + max_media_attachments: 4, + }, + polls: { + max_options: 4, + max_characters_per_option: 50, + min_expiration: 300, + max_expiration: 2629746, + }, + translation: { + enabled: true, + }, + }, + registrations: { + enabled: false, + approval_required: false, + message: null, + }, + }; + + expect(result.toJS()).toMatchObject(expected); + }); }); describe('ADMIN_CONFIG_UPDATE_REQUEST', () => { @@ -127,13 +160,13 @@ describe('instance reducer', () => { configs, }; - // The normalizer has `registrations: closed` by default + // The normalizer has `registrations.enabled: closed` by default const state = reducer(undefined, {} as any); - expect(state.registrations).toBe(false); + expect(state.registrations.get('enabled')).toBe(false); // After importing the configs, registration will be open const result = reducer(state, action); - expect(result.registrations).toBe(true); + expect(result.registrations.get('enabled')).toBe(true); }); }); }); diff --git a/app/soapbox/reducers/instance.ts b/app/soapbox/reducers/instance.ts index 1c15891e9..6ac55d5c5 100644 --- a/app/soapbox/reducers/instance.ts +++ b/app/soapbox/reducers/instance.ts @@ -65,8 +65,8 @@ const importConfigs = (state: typeof initialState, configs: ImmutableList) const registrationsOpen = getConfigValue(value, ':registrations_open'); const approvalRequired = getConfigValue(value, ':account_approval_required'); - state.update('registrations', c => typeof registrationsOpen === 'boolean' ? registrationsOpen : c); - state.update('approval_required', c => typeof approvalRequired === 'boolean' ? approvalRequired : c); + state.updateIn(['registrations', 'enabled'], c => typeof registrationsOpen === 'boolean' ? registrationsOpen : c); + state.updateIn(['registrations', 'approval_required'], c => typeof approvalRequired === 'boolean' ? approvalRequired : c); } if (simplePolicy) { @@ -95,8 +95,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); @@ -116,14 +115,14 @@ export default function instance(state = initialState, action: AnyAction) { case PLEROMA_PRELOAD_IMPORT: return preloadImport(state, action, '/api/v1/instance'); case rememberInstance.fulfilled.type: - return importInstance(state, ImmutableMap(fromJS(action.payload))); + return importInstance(state, ImmutableMap(fromJS(action.payload.instance))); case fetchInstance.fulfilled.type: persistInstance(action.payload); - return importInstance(state, ImmutableMap(fromJS(action.payload))); + return importInstance(state, ImmutableMap(fromJS(action.payload.instance))); case fetchInstance.rejected.type: return handleInstanceFetchFail(state, action.error); case fetchNodeinfo.fulfilled.type: - return importNodeinfo(state, ImmutableMap(fromJS(action.payload))); + return importNodeinfo(state, ImmutableMap(fromJS(action.payload.instance))); case ADMIN_CONFIG_UPDATE_REQUEST: case ADMIN_CONFIG_UPDATE_SUCCESS: return importConfigs(state, ImmutableList(fromJS(action.configs))); diff --git a/app/soapbox/stream.ts b/app/soapbox/stream.ts index 8b09a86ad..df7cf9416 100644 --- a/app/soapbox/stream.ts +++ b/app/soapbox/stream.ts @@ -14,7 +14,7 @@ export function connectStream( callbacks: (dispatch: AppDispatch, getState: () => RootState) => Record = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} }), ) { return (dispatch: AppDispatch, getState: () => RootState) => { - const streamingAPIBaseURL = getState().instance.urls.get('streaming_api'); + const streamingAPIBaseURL = getState().instance.configuration.getIn(['urls', 'streaming']) as string; const accessToken = getAccessToken(getState()); const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState); @@ -40,7 +40,7 @@ export function connectStream( // If the WebSocket fails to be created, don't crash the whole page, // just proceed without a subscription. try { - subscription = getStream(streamingAPIBaseURL!, accessToken, path, { + subscription = getStream(streamingAPIBaseURL, accessToken, path, { connected() { if (pollingRefresh) { clearPolling(); diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 8314cabeb..c4f5ea6cd 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -628,7 +628,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.getIn(['translation', 'enabled']), /** * Trending statuses.