Merge branch 'instance-v2' into 'main'

Remove instance normalizer, support /v2/instance

See merge request soapbox-pub/soapbox!2846
environments/review-main-yi2y9f/deployments/4347
marcin mikołajczak 2024-01-04 23:47:45 +00:00
commit 3e076e5e9a
25 zmienionych plików z 428 dodań i 449 usunięć

Wyświetl plik

@ -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<string, any>): 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<string, any>): 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<void, void, { state: RootState }>(
export const fetchInstance = createAsyncThunk<{ instance: Record<string, any>; 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<string, any>; host?: string | null };
}
if (needsNodeinfo(instance)) {
dispatch(fetchNodeinfo());
}
return instance;
return { instance, host };
} catch (e) {
return rejectWithValue(e);
}
},
);
export const fetchInstanceV2 = createAsyncThunk<{ instance: Record<string, any>; 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<void, void, { state: RootState }>(
'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));
}
},
);

Wyświetl plik

@ -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)',
}),
};

Wyświetl plik

@ -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',

Wyświetl plik

@ -14,7 +14,7 @@ function useTimelineStream(...args: Parameters<typeof connectTimelineStream>) {
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) {

Wyświetl plik

@ -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" */

Wyświetl plik

@ -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('<LoginForm />', () => {
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('<LoginForm />', () => {
it('renders for Mastodon', () => {
const mockFn = vi.fn();
const store = {
instance: normalizeInstance({
instance: instanceSchema.parse({
version: '3.0.0',
}),
};

Wyświetl plik

@ -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('<LoginPage />', () => {
it('renders correctly on load', () => {
const store = {
instance: normalizeInstance({
instance: instanceSchema.parse({
version: '2.7.2 (compatible; Pleroma 2.3.0)',
}),
};

Wyświetl plik

@ -47,7 +47,7 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ 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;

Wyświetl plik

@ -12,7 +12,7 @@ interface IUploadCompose {
const UploadCompose: React.FC<IUploadCompose> = ({ 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)!;

Wyświetl plik

@ -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)',
}),
};

Wyświetl plik

@ -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',
}),

Wyświetl plik

@ -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 (
<Stack space={3}>

Wyświetl plik

@ -339,7 +339,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<WrappedRoute path='/about/:slug?' page={DefaultPage} component={AboutPage} publicRoute exact />
{(features.accountCreation && instance.registrations) && (
{(features.accountCreation && instance.registrations.enabled) && (
<WrappedRoute path='/signup' page={EmptyPage} component={RegistrationPage} publicRoute exact />
)}

Wyświetl plik

@ -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,

Wyświetl plik

@ -7,6 +7,6 @@ export const useRegistrationStatus = () => {
return {
/** Registrations are open. */
isOpen: features.accountCreation && instance.registrations,
isOpen: features.accountCreation && instance.registrations.enabled,
};
};

Wyświetl plik

@ -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 = {

Wyświetl plik

@ -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';

Wyświetl plik

@ -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)');
});
});

Wyświetl plik

@ -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<string, any>(),
configuration: ImmutableMap<string, any>({
media_attachments: ImmutableMap<string, any>(),
chats: ImmutableMap<string, number>({
max_characters: 5000,
max_media_attachments: 1,
}),
polls: ImmutableMap<string, number>({
max_options: 4,
max_characters_per_option: 25,
min_expiration: 300,
max_expiration: 2629746,
}),
statuses: ImmutableMap<string, number>({
max_characters: 500,
max_media_attachments: 4,
}),
groups: ImmutableMap<string, number>({
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<string, any>({
metadata: ImmutableMap<string, any>({
account_activation_required: false,
birthday_min_age: 0,
birthday_required: false,
features: ImmutableList(),
federation: ImmutableMap<string, any>({
enabled: true,
exclusions: false,
}),
}),
stats: ImmutableMap(),
}),
registrations: false,
rules: ImmutableList(),
short_description: '',
stats: ImmutableMap<string, number>({
domain_count: 0,
status_count: 0,
user_count: 0,
}),
nostr: ImmutableMap<string, any>({
relay: undefined as string | undefined,
pubkey: undefined as string | undefined,
}),
title: '',
thumbnail: '',
uri: '',
urls: ImmutableMap<string, string>(),
version: '0.0.0',
});
// Build Mastodon configuration from Pleroma instance
const pleromaToMastodonConfig = (instance: ImmutableMap<string, any>) => {
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<string, any>) => {
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<string, any>) => {
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<string, any>) => {
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<string, any>) => {
return InstanceRecord(
ImmutableMap(fromJS(instance)).withMutations((instance: ImmutableMap<string, any>) => {
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());
}),
);
};

Wyświetl plik

@ -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<any>)
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:

Wyświetl plik

@ -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)');
});
});

Wyświetl plik

@ -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<typeof instanceSchema>;

Wyświetl plik

@ -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);

Wyświetl plik

@ -12,7 +12,6 @@ import {
FilterKeywordRecord,
FilterStatusRecord,
HistoryRecord,
InstanceRecord,
ListRecord,
LocationRecord,
MentionRecord,
@ -41,7 +40,6 @@ type Filter = ReturnType<typeof FilterRecord>;
type FilterKeyword = ReturnType<typeof FilterKeywordRecord>;
type FilterStatus = ReturnType<typeof FilterStatusRecord>;
type History = ReturnType<typeof HistoryRecord>;
type Instance = ReturnType<typeof InstanceRecord>;
type List = ReturnType<typeof ListRecord>;
type Location = ReturnType<typeof LocationRecord>;
type Mention = ReturnType<typeof MentionRecord>;
@ -77,7 +75,6 @@ export {
FilterKeyword,
FilterStatus,
History,
Instance,
List,
Location,
Mention,

Wyświetl plik

@ -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.