kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge branch 'instance-hooks' into 'main'
Refactor Instance fetching See merge request soapbox-pub/soapbox!3148environments/review-main-yi2y9f/deployments/4893
commit
943c5fddee
|
@ -24,7 +24,6 @@ import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth';
|
|||
import sourceCode from 'soapbox/utils/code';
|
||||
import { normalizeUsername } from 'soapbox/utils/input';
|
||||
import { getScopes } from 'soapbox/utils/scopes';
|
||||
import { isStandalone } from 'soapbox/utils/state';
|
||||
|
||||
import api, { baseClient } from '../api';
|
||||
|
||||
|
@ -201,11 +200,10 @@ export const logIn = (username: string, password: string) =>
|
|||
export const deleteSession = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => api(getState).delete('/api/sign_out');
|
||||
|
||||
export const logOut = () =>
|
||||
export const logOut = (refresh = true) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const account = getLoggedInAccount(state);
|
||||
const standalone = isStandalone(state);
|
||||
|
||||
if (!account) return dispatch(noOp);
|
||||
|
||||
|
@ -229,7 +227,7 @@ export const logOut = () =>
|
|||
localStorage.removeItem('soapbox:external:baseurl');
|
||||
localStorage.removeItem('soapbox:external:scopes');
|
||||
|
||||
dispatch({ type: AUTH_LOGGED_OUT, account, standalone });
|
||||
dispatch({ type: AUTH_LOGGED_OUT, account, refresh });
|
||||
|
||||
toast.success(messages.loggedOut);
|
||||
});
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { isLoggedIn } from 'soapbox/utils/auth';
|
||||
import { getNextLinkName } from 'soapbox/utils/quirks';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
|
@ -18,14 +17,13 @@ const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL';
|
|||
|
||||
const fetchBlocks = () => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (!isLoggedIn(getState)) return null;
|
||||
const nextLinkName = getNextLinkName(getState);
|
||||
|
||||
dispatch(fetchBlocksRequest());
|
||||
|
||||
return api(getState)
|
||||
.get('/api/v1/blocks')
|
||||
.then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === nextLinkName);
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map((item: any) => item.id)) as any);
|
||||
|
@ -54,7 +52,6 @@ function fetchBlocksFail(error: unknown) {
|
|||
|
||||
const expandBlocks = () => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (!isLoggedIn(getState)) return null;
|
||||
const nextLinkName = getNextLinkName(getState);
|
||||
|
||||
const url = getState().user_lists.blocks.next;
|
||||
|
||||
|
@ -67,7 +64,7 @@ const expandBlocks = () => (dispatch: AppDispatch, getState: () => RootState) =>
|
|||
return api(getState)
|
||||
.get(url)
|
||||
.then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === nextLinkName);
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(expandBlocksSuccess(response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map((item: any) => item.id)) as any);
|
||||
|
|
|
@ -9,10 +9,9 @@
|
|||
import { createApp } from 'soapbox/actions/apps';
|
||||
import { authLoggedIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth';
|
||||
import { obtainOAuthToken } from 'soapbox/actions/oauth';
|
||||
import { instanceSchema, type Instance } from 'soapbox/schemas';
|
||||
import { InstanceV1, instanceV1Schema } from 'soapbox/schemas/instance';
|
||||
import { parseBaseURL } from 'soapbox/utils/auth';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
import { getQuirks } from 'soapbox/utils/quirks';
|
||||
import { getInstanceScopes } from 'soapbox/utils/scopes';
|
||||
|
||||
import { baseClient } from '../api';
|
||||
|
@ -22,36 +21,33 @@ import type { AppDispatch, RootState } from 'soapbox/store';
|
|||
const fetchExternalInstance = (baseURL?: string) => {
|
||||
return baseClient(null, baseURL)
|
||||
.get('/api/v1/instance')
|
||||
.then(({ data: instance }) => instanceSchema.parse(instance))
|
||||
.then(({ data: instance }) => instanceV1Schema.parse(instance))
|
||||
.catch(error => {
|
||||
if (error.response?.status === 401) {
|
||||
// Authenticated fetch is enabled.
|
||||
// Continue with a limited featureset.
|
||||
return instanceSchema.parse({});
|
||||
return instanceV1Schema.parse({});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createExternalApp = (instance: Instance, baseURL?: string) =>
|
||||
const createExternalApp = (instance: InstanceV1, baseURL?: string) =>
|
||||
(dispatch: AppDispatch, _getState: () => RootState) => {
|
||||
// Mitra: skip creating the auth app
|
||||
if (getQuirks(instance).noApps) return new Promise(f => f({}));
|
||||
|
||||
const params = {
|
||||
client_name: sourceCode.displayName,
|
||||
redirect_uris: `${window.location.origin}/login/external`,
|
||||
website: sourceCode.homepage,
|
||||
scopes: getInstanceScopes(instance),
|
||||
scopes: getInstanceScopes(instance.version),
|
||||
};
|
||||
|
||||
return dispatch(createApp(params, baseURL));
|
||||
};
|
||||
|
||||
const externalAuthorize = (instance: Instance, baseURL: string) =>
|
||||
const externalAuthorize = (instance: InstanceV1, baseURL: string) =>
|
||||
(dispatch: AppDispatch, _getState: () => RootState) => {
|
||||
const scopes = getInstanceScopes(instance);
|
||||
const scopes = getInstanceScopes(instance.version);
|
||||
|
||||
return dispatch(createExternalApp(instance, baseURL)).then((app) => {
|
||||
const { client_id, redirect_uri } = app as Record<string, string>;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import { instanceSchema } from 'soapbox/schemas';
|
||||
import { instanceV1Schema, instanceV2Schema } from 'soapbox/schemas/instance';
|
||||
import { RootState } from 'soapbox/store';
|
||||
import { getAuthUserUrl, getMeUrl } from 'soapbox/utils/auth';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
@ -28,7 +28,7 @@ export const fetchInstance = createAsyncThunk<InstanceData, InstanceData['host']
|
|||
async(host, { dispatch, getState, rejectWithValue }) => {
|
||||
try {
|
||||
const { data } = await api(getState).get('/api/v1/instance');
|
||||
const instance = instanceSchema.parse(data);
|
||||
const instance = instanceV1Schema.parse(data);
|
||||
const features = getFeatures(instance);
|
||||
|
||||
if (features.instanceV2) {
|
||||
|
@ -46,7 +46,8 @@ export const fetchInstanceV2 = createAsyncThunk<InstanceData, InstanceData['host
|
|||
'instanceV2/fetch',
|
||||
async(host, { getState, rejectWithValue }) => {
|
||||
try {
|
||||
const { data: instance } = await api(getState).get('/api/v2/instance');
|
||||
const { data } = await api(getState).get('/api/v2/instance');
|
||||
const instance = instanceV2Schema.parse(data);
|
||||
return { instance, host };
|
||||
} catch (e) {
|
||||
return rejectWithValue(e);
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { instanceSchema } from 'soapbox/schemas';
|
||||
import { instanceV1Schema } from 'soapbox/schemas/instance';
|
||||
|
||||
import { useGroups } from './useGroups';
|
||||
|
||||
const group = buildGroup({ id: '1', display_name: 'soapbox' });
|
||||
const store = {
|
||||
instance: instanceSchema.parse({
|
||||
instance: instanceV1Schema.parse({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -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 { instanceSchema } from 'soapbox/schemas';
|
||||
import { instanceV1Schema } from 'soapbox/schemas/instance';
|
||||
|
||||
import { usePendingGroups } from './usePendingGroups';
|
||||
|
||||
const id = '1';
|
||||
const group = buildGroup({ id, display_name: 'soapbox' });
|
||||
const store = {
|
||||
instance: instanceSchema.parse({
|
||||
instance: instanceV1Schema.parse({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
|
||||
}),
|
||||
me: '1',
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { InstanceV1, instanceV1Schema } from 'soapbox/schemas/instance';
|
||||
|
||||
interface Opts extends Pick<UseQueryOptions<unknown>, 'enabled' | 'retry' | 'retryOnMount' | 'staleTime'> {
|
||||
/** The base URL of the instance. */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
/** Get the Instance for the current backend. */
|
||||
export function useInstanceV1(opts: Opts = {}) {
|
||||
const api = useApi();
|
||||
|
||||
const { baseUrl } = opts;
|
||||
|
||||
const { data: instance, ...rest } = useQuery<InstanceV1>({
|
||||
queryKey: ['instance', baseUrl ?? api.baseUrl, 'v1'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/v1/instance');
|
||||
const data = await response.json();
|
||||
return instanceV1Schema.parse(data);
|
||||
},
|
||||
...opts,
|
||||
});
|
||||
|
||||
return { instance, ...rest };
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { InstanceV2, instanceV2Schema } from 'soapbox/schemas/instance';
|
||||
|
||||
interface Opts extends Pick<UseQueryOptions<unknown>, 'enabled' | 'retry' | 'retryOnMount' | 'staleTime'> {
|
||||
/** The base URL of the instance. */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
/** Get the Instance for the current backend. */
|
||||
export function useInstanceV2(opts: Opts = {}) {
|
||||
const api = useApi();
|
||||
|
||||
const { baseUrl } = opts;
|
||||
|
||||
const { data: instance, ...rest } = useQuery<InstanceV2>({
|
||||
queryKey: ['instance', baseUrl ?? api.baseUrl, 'v2'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/v2/instance');
|
||||
const data = await response.json();
|
||||
return instanceV2Schema.parse(data);
|
||||
},
|
||||
...opts,
|
||||
});
|
||||
|
||||
return { instance, ...rest };
|
||||
}
|
|
@ -4,7 +4,7 @@ import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
|||
import { updateConfig } from 'soapbox/actions/admin';
|
||||
import { RadioGroup, RadioItem } from 'soapbox/components/radio';
|
||||
import { useAppDispatch, useInstance } from 'soapbox/hooks';
|
||||
import { Instance } from 'soapbox/schemas';
|
||||
import { InstanceV2 } from 'soapbox/schemas/instance';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
type RegistrationMode = 'open' | 'approval' | 'closed';
|
||||
|
@ -27,7 +27,7 @@ const generateConfig = (mode: RegistrationMode) => {
|
|||
}];
|
||||
};
|
||||
|
||||
const modeFromInstance = ({ registrations }: Instance): RegistrationMode => {
|
||||
const modeFromInstance = ({ registrations }: InstanceV2): RegistrationMode => {
|
||||
if (registrations.approval_required && registrations.enabled) return 'approval';
|
||||
return registrations.enabled ? 'open' : 'closed';
|
||||
};
|
||||
|
|
|
@ -2,9 +2,10 @@ import React from 'react';
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email-list';
|
||||
import { useInstanceV1 } from 'soapbox/api/hooks/instance/useInstanceV1';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { CardTitle, Icon, IconButton, Stack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useOwnAccount, useFeatures, useInstance } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useOwnAccount, useFeatures } from 'soapbox/hooks';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
import { download } from 'soapbox/utils/download';
|
||||
import { parseVersion } from 'soapbox/utils/features';
|
||||
|
@ -14,7 +15,7 @@ import RegistrationModePicker from '../components/registration-mode-picker';
|
|||
|
||||
const Dashboard: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { instance } = useInstance();
|
||||
const { instance } = useInstanceV1();
|
||||
const features = useFeatures();
|
||||
const { account } = useOwnAccount();
|
||||
|
||||
|
@ -39,15 +40,15 @@ const Dashboard: React.FC = () => {
|
|||
e.preventDefault();
|
||||
};
|
||||
|
||||
const v = parseVersion(instance.version);
|
||||
const v = parseVersion(instance?.version ?? '0.0.0');
|
||||
|
||||
const {
|
||||
user_count: userCount,
|
||||
status_count: statusCount,
|
||||
domain_count: domainCount,
|
||||
} = instance.stats;
|
||||
} = instance?.stats ?? {};
|
||||
|
||||
const mau = instance.pleroma.stats.mau;
|
||||
const mau = instance?.pleroma.stats.mau;
|
||||
const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : undefined;
|
||||
|
||||
if (!account) return null;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { fireEvent, render, screen } from 'soapbox/jest/test-helpers';
|
||||
import { instanceSchema } from 'soapbox/schemas';
|
||||
import { instanceV1Schema } from 'soapbox/schemas/instance';
|
||||
|
||||
import LoginForm from './login-form';
|
||||
|
||||
|
@ -9,7 +9,7 @@ describe('<LoginForm />', () => {
|
|||
it('renders for Pleroma', () => {
|
||||
const mockFn = vi.fn();
|
||||
const store = {
|
||||
instance: instanceSchema.parse({
|
||||
instance: instanceV1Schema.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: instanceSchema.parse({
|
||||
instance: instanceV1Schema.parse({
|
||||
version: '3.0.0',
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import React from 'react';
|
||||
|
||||
import { render, screen } from 'soapbox/jest/test-helpers';
|
||||
import { instanceSchema } from 'soapbox/schemas';
|
||||
import { instanceV1Schema } from 'soapbox/schemas/instance';
|
||||
|
||||
import LoginPage from './login-page';
|
||||
|
||||
describe('<LoginPage />', () => {
|
||||
it('renders correctly on load', () => {
|
||||
const store = {
|
||||
instance: instanceSchema.parse({
|
||||
instance: instanceV1Schema.parse({
|
||||
version: '2.7.2 (compatible; Pleroma 2.3.0)',
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -6,9 +6,8 @@ import { logIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth';
|
|||
import { fetchInstance } from 'soapbox/actions/instance';
|
||||
import { closeModal, openModal } from 'soapbox/actions/modals';
|
||||
import { BigCard } from 'soapbox/components/big-card';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector, useFeatures, useInstance } from 'soapbox/hooks';
|
||||
import { getRedirectUrl } from 'soapbox/utils/redirect';
|
||||
import { isStandalone } from 'soapbox/utils/state';
|
||||
|
||||
import ConsumersList from './consumers-list';
|
||||
import LoginForm from './login-form';
|
||||
|
@ -20,7 +19,7 @@ const LoginPage = () => {
|
|||
const dispatch = useAppDispatch();
|
||||
|
||||
const me = useAppSelector((state) => state.me);
|
||||
const standalone = useAppSelector((state) => isStandalone(state));
|
||||
const instance = useInstance();
|
||||
const { nostrSignup } = useFeatures();
|
||||
|
||||
const token = new URLSearchParams(window.location.search).get('token');
|
||||
|
@ -68,7 +67,9 @@ const LoginPage = () => {
|
|||
return <Redirect to='/' />;
|
||||
}
|
||||
|
||||
if (standalone) return <Redirect to='/login/external' />;
|
||||
if (instance.isNotFound) {
|
||||
return <Redirect to='/login/external' />;
|
||||
}
|
||||
|
||||
if (shouldRedirect) {
|
||||
const redirectUri = getRedirectUrl();
|
||||
|
|
|
@ -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 { instanceSchema } from 'soapbox/schemas';
|
||||
import { instanceV1Schema } from 'soapbox/schemas/instance';
|
||||
|
||||
import Search from './search';
|
||||
|
||||
const store = {
|
||||
instance: instanceSchema.parse({
|
||||
instance: instanceV1Schema.parse({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -3,7 +3,7 @@ import React from 'react';
|
|||
|
||||
import { buildAccount } from 'soapbox/jest/factory';
|
||||
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { instanceSchema } from 'soapbox/schemas';
|
||||
import { instanceV1Schema } from 'soapbox/schemas/instance';
|
||||
|
||||
import Discover from './discover';
|
||||
|
||||
|
@ -32,9 +32,8 @@ const store: any = {
|
|||
},
|
||||
}),
|
||||
},
|
||||
instance: instanceSchema.parse({
|
||||
instance: instanceV1Schema.parse({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
|
||||
software: 'TRUTHSOCIAL',
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
|
@ -10,10 +10,9 @@ import { openSidebar } from 'soapbox/actions/sidebar';
|
|||
import SiteLogo from 'soapbox/components/site-logo';
|
||||
import { Avatar, Button, Counter, Form, HStack, IconButton, Input, Tooltip } from 'soapbox/components/ui';
|
||||
import Search from 'soapbox/features/compose/components/search';
|
||||
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useRegistrationStatus } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useFeatures, useInstance, useOwnAccount, useRegistrationStatus } from 'soapbox/hooks';
|
||||
import { useIsMobile } from 'soapbox/hooks/useIsMobile';
|
||||
import { useSettingsNotifications } from 'soapbox/hooks/useSettingsNotifications';
|
||||
import { isStandalone } from 'soapbox/utils/state';
|
||||
|
||||
import ProfileDropdown from './profile-dropdown';
|
||||
|
||||
|
@ -31,7 +30,7 @@ const Navbar = () => {
|
|||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const features = useFeatures();
|
||||
const standalone = useAppSelector(isStandalone);
|
||||
const instance = useInstance();
|
||||
const { isOpen } = useRegistrationStatus();
|
||||
const { account } = useOwnAccount();
|
||||
const node = useRef(null);
|
||||
|
@ -121,7 +120,7 @@ const Navbar = () => {
|
|||
)}
|
||||
</HStack>
|
||||
|
||||
{!standalone && (
|
||||
{instance.isSuccess && (
|
||||
<HStack space={3} alignItems='center' className='absolute inset-y-0 right-0 pr-2 lg:static lg:inset-auto lg:ml-6 lg:pr-0'>
|
||||
{account ? (
|
||||
<div className='relative hidden items-center lg:flex'>
|
||||
|
|
|
@ -35,7 +35,6 @@ import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
|||
import SearchPage from 'soapbox/pages/search-page';
|
||||
import StatusPage from 'soapbox/pages/status-page';
|
||||
import { getVapidKey } from 'soapbox/utils/auth';
|
||||
import { isStandalone } from 'soapbox/utils/state';
|
||||
|
||||
import BackgroundShapes from './components/background-shapes';
|
||||
import FloatingActionButton from './components/floating-action-button';
|
||||
|
@ -157,11 +156,10 @@ interface ISwitchingColumnsArea {
|
|||
}
|
||||
|
||||
const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) => {
|
||||
const { instance } = useInstance();
|
||||
const { instance, isNotFound } = useInstance();
|
||||
const features = useFeatures();
|
||||
const { search } = useLocation();
|
||||
const { isLoggedIn } = useLoggedIn();
|
||||
const standalone = useAppSelector(isStandalone);
|
||||
|
||||
const { authenticatedProfile, cryptoAddresses } = useSoapboxConfig();
|
||||
const hasCrypto = cryptoAddresses.size > 0;
|
||||
|
@ -173,7 +171,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
|||
// Ex: use /login instead of /auth, but redirect /auth to /login
|
||||
return (
|
||||
<Switch>
|
||||
{standalone && <Redirect from='/' to='/login/external' exact />}
|
||||
{isNotFound && <Redirect from='/' to='/login/external' exact />}
|
||||
|
||||
<WrappedRoute path='/email-confirmation' page={EmptyPage} component={EmailConfirmation} publicRoute exact />
|
||||
<WrappedRoute path='/logout' page={EmptyPage} component={LogoutPage} publicRoute exact />
|
||||
|
@ -388,11 +386,11 @@ const UI: React.FC<IUI> = ({ children }) => {
|
|||
const node = useRef<HTMLDivElement | null>(null);
|
||||
const me = useAppSelector(state => state.me);
|
||||
const { account } = useOwnAccount();
|
||||
const instance = useInstance();
|
||||
const features = useFeatures();
|
||||
const vapidKey = useAppSelector(state => getVapidKey(state));
|
||||
|
||||
const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.isOpen);
|
||||
const standalone = useAppSelector(isStandalone);
|
||||
|
||||
const { isDragging } = useDraggedFiles(node);
|
||||
|
||||
|
@ -503,7 +501,7 @@ const UI: React.FC<IUI> = ({ children }) => {
|
|||
|
||||
<Layout>
|
||||
<Layout.Sidebar>
|
||||
{!standalone && <SidebarNavigation />}
|
||||
{instance.isSuccess && <SidebarNavigation />}
|
||||
</Layout.Sidebar>
|
||||
|
||||
<SwitchingColumnsArea>
|
||||
|
|
|
@ -1,12 +1,18 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { MastodonClient } from 'soapbox/api/MastodonClient';
|
||||
import * as BuildConfig from 'soapbox/build-config';
|
||||
|
||||
import { useAppSelector } from './useAppSelector';
|
||||
import { useOwnAccount } from './useOwnAccount';
|
||||
|
||||
export function useApi(): MastodonClient {
|
||||
const { account } = useOwnAccount();
|
||||
const authUserUrl = useAppSelector((state) => state.auth.me);
|
||||
const accessToken = useAppSelector((state) => account ? state.auth.users.get(account.url)?.access_token : undefined);
|
||||
const baseUrl = account ? new URL(account.url).origin : location.origin;
|
||||
const baseUrl = new URL(BuildConfig.BACKEND_URL || account?.url || authUserUrl || location.origin).origin;
|
||||
|
||||
return new MastodonClient(baseUrl, accessToken);
|
||||
return useMemo(() => {
|
||||
return new MastodonClient(baseUrl, accessToken);
|
||||
}, [baseUrl, accessToken]);
|
||||
}
|
|
@ -1,7 +1,54 @@
|
|||
import { useAppSelector } from './useAppSelector';
|
||||
import { UseQueryOptions } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { HTTPError } from 'soapbox/api/HTTPError';
|
||||
import { useInstanceV1 } from 'soapbox/api/hooks/instance/useInstanceV1';
|
||||
import { useInstanceV2 } from 'soapbox/api/hooks/instance/useInstanceV2';
|
||||
import { instanceV2Schema, upgradeInstance } from 'soapbox/schemas/instance';
|
||||
|
||||
import { useAppDispatch } from './useAppDispatch';
|
||||
|
||||
interface Opts extends Pick<UseQueryOptions<unknown>, 'enabled' | 'retryOnMount' | 'staleTime'> {
|
||||
/** The base URL of the instance. */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
/** Get the Instance for the current backend. */
|
||||
export const useInstance = () => {
|
||||
const instance = useAppSelector((state) => state.instance);
|
||||
return { instance };
|
||||
};
|
||||
export function useInstance(opts: Opts = {}) {
|
||||
const { baseUrl, retryOnMount = false, staleTime = Infinity } = opts;
|
||||
|
||||
function retry(failureCount: number, error: Error): boolean {
|
||||
if (error instanceof HTTPError && error.response.status === 404) {
|
||||
return false;
|
||||
} else {
|
||||
return failureCount < 3;
|
||||
}
|
||||
}
|
||||
|
||||
const v2 = useInstanceV2({ baseUrl, retry, retryOnMount, staleTime });
|
||||
const v1 = useInstanceV1({ baseUrl, retry, retryOnMount, staleTime, enabled: v2.isError });
|
||||
|
||||
const instance = useMemo(() => {
|
||||
if (v2.instance) {
|
||||
return v2.instance;
|
||||
} if (v1.instance) {
|
||||
return upgradeInstance(v1.instance);
|
||||
} else {
|
||||
return instanceV2Schema.parse({});
|
||||
}
|
||||
}, [v2.instance, v1.instance]);
|
||||
|
||||
const props = v2.isError ? v1 : v2;
|
||||
const isNotFound = props.error instanceof HTTPError && props.error.response.status === 404;
|
||||
|
||||
// HACK: store the instance in Redux for legacy code
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
dispatch({
|
||||
type: 'instanceV2/fetch/fulfilled',
|
||||
payload: { instance },
|
||||
});
|
||||
}, [instance]);
|
||||
|
||||
return { ...props, instance, isNotFound };
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import { fetchInstance } from 'soapbox/actions/instance';
|
||||
import { fetchMe } from 'soapbox/actions/me';
|
||||
import { loadSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import { useSignerStream } from 'soapbox/api/hooks/nostr/useSignerStream';
|
||||
|
@ -12,17 +11,16 @@ import {
|
|||
useAppDispatch,
|
||||
useOwnAccount,
|
||||
useLocale,
|
||||
useInstance,
|
||||
} from 'soapbox/hooks';
|
||||
import MESSAGES from 'soapbox/messages';
|
||||
|
||||
/** Load initial data from the backend */
|
||||
const loadInitial = () => {
|
||||
// @ts-ignore
|
||||
return async(dispatch, getState) => {
|
||||
return async(dispatch) => {
|
||||
// Await for authenticated fetch
|
||||
await dispatch(fetchMe());
|
||||
// Await for feature detection
|
||||
await dispatch(fetchInstance());
|
||||
// Await for configuration
|
||||
await dispatch(loadSoapboxConfig());
|
||||
};
|
||||
|
@ -38,6 +36,7 @@ const SoapboxLoad: React.FC<ISoapboxLoad> = ({ children }) => {
|
|||
|
||||
const me = useAppSelector(state => state.me);
|
||||
const { account } = useOwnAccount();
|
||||
const instance = useInstance();
|
||||
const swUpdating = useAppSelector(state => state.meta.swUpdating);
|
||||
const { locale } = useLocale();
|
||||
|
||||
|
@ -54,6 +53,7 @@ const SoapboxLoad: React.FC<ISoapboxLoad> = ({ children }) => {
|
|||
me && !account,
|
||||
!isLoaded,
|
||||
localeLoading,
|
||||
instance.isLoading,
|
||||
swUpdating,
|
||||
hasNostr && me && (!isRelayOpen || !isSubscribed),
|
||||
].some(Boolean);
|
||||
|
@ -68,12 +68,14 @@ const SoapboxLoad: React.FC<ISoapboxLoad> = ({ children }) => {
|
|||
|
||||
// Load initial data from the API
|
||||
useEffect(() => {
|
||||
dispatch(loadInitial()).then(() => {
|
||||
setIsLoaded(true);
|
||||
}).catch(() => {
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
if (!instance.isLoading) {
|
||||
dispatch(loadInitial()).then(() => {
|
||||
setIsLoaded(true);
|
||||
}).catch(() => {
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}
|
||||
}, [instance.isLoading]);
|
||||
|
||||
// intl is part of loading.
|
||||
// It's important nothing in here depends on intl.
|
||||
|
|
|
@ -35,7 +35,7 @@ const SoapboxMount = () => {
|
|||
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
||||
const showCaptcha = account && account?.source?.ditto.captcha_solved === false;
|
||||
const showCaptcha = account?.source?.ditto.captcha_solved === false;
|
||||
const needsOnboarding = useAppSelector(state => state.onboarding.needsOnboarding);
|
||||
const showOnboarding = account && needsOnboarding;
|
||||
|
||||
|
@ -48,7 +48,7 @@ const SoapboxMount = () => {
|
|||
if (showOnboarding) {
|
||||
dispatch(openModal('ONBOARDING_FLOW'));
|
||||
}
|
||||
|
||||
|
||||
const { redirectRootNoLogin, gdpr } = soapboxConfig;
|
||||
|
||||
// @ts-ignore: I don't actually know what these should be, lol
|
||||
|
|
|
@ -15,10 +15,9 @@ import {
|
|||
type GroupTag,
|
||||
type Relationship,
|
||||
type Status,
|
||||
Instance,
|
||||
instanceSchema,
|
||||
} from 'soapbox/schemas';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
import { InstanceV2, instanceV2Schema } from 'soapbox/schemas/instance';
|
||||
|
||||
import type { PartialDeep } from 'type-fest';
|
||||
|
||||
|
@ -71,8 +70,8 @@ function buildGroupMember(
|
|||
}, props));
|
||||
}
|
||||
|
||||
function buildInstance(props: PartialDeep<Instance> = {}) {
|
||||
return instanceSchema.parse(props);
|
||||
function buildInstance(props: PartialDeep<InstanceV2> = {}) {
|
||||
return instanceV2Schema.parse(props);
|
||||
}
|
||||
|
||||
function buildRelationship(props: PartialDeep<Relationship> = {}): Relationship {
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import alexJson from 'soapbox/__fixtures__/pleroma-account.json';
|
||||
import { instanceSchema } from 'soapbox/schemas';
|
||||
import { instanceV1Schema } from 'soapbox/schemas/instance';
|
||||
|
||||
import { buildAccount } from './factory';
|
||||
|
||||
/** Store with registrations open. */
|
||||
const storeOpen = { instance: instanceSchema.parse({ registrations: true }) };
|
||||
const storeOpen = { instance: instanceV1Schema.parse({ registrations: true }) };
|
||||
|
||||
/** Store with registrations closed. */
|
||||
const storeClosed = { instance: instanceSchema.parse({ registrations: false }) };
|
||||
const storeClosed = { instance: instanceV1Schema.parse({ registrations: false }) };
|
||||
|
||||
/** Store with a logged-in user. */
|
||||
const storeLoggedIn = {
|
||||
|
|
|
@ -23,7 +23,6 @@ import './styles/i18n/javanese.css';
|
|||
import './styles/application.scss';
|
||||
import './styles/tailwind.css';
|
||||
|
||||
import './precheck';
|
||||
import ready from './ready';
|
||||
import { registerSW, lockSW } from './utils/sw';
|
||||
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
/**
|
||||
* Precheck: information about the site before anything renders.
|
||||
* @module soapbox/precheck
|
||||
*/
|
||||
|
||||
/** Whether pre-rendered data exists in Pleroma's format. */
|
||||
const hasPrerenderPleroma = Boolean(document.getElementById('initial-results'));
|
||||
|
||||
/** Whether pre-rendered data exists in Mastodon's format. */
|
||||
const hasPrerenderMastodon = Boolean(document.getElementById('initial-state'));
|
||||
|
||||
/** Whether initial data was loaded into the page by server-side-rendering (SSR). */
|
||||
export const isPrerendered = hasPrerenderPleroma || hasPrerenderMastodon;
|
|
@ -378,10 +378,10 @@ const userSwitched = (oldState: State, state: State) => {
|
|||
};
|
||||
|
||||
const maybeReload = (oldState: State, state: State, action: AnyAction) => {
|
||||
const loggedOutStandalone = action.type === AUTH_LOGGED_OUT && action.standalone;
|
||||
const shouldRefresh = action.type === AUTH_LOGGED_OUT && action.refresh;
|
||||
const switched = userSwitched(oldState, state);
|
||||
|
||||
if (switched || loggedOutStandalone) {
|
||||
if (switched || shouldRefresh) {
|
||||
reload();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import { combineReducers } from '@reduxjs/toolkit';
|
||||
|
||||
import { AUTH_LOGGED_OUT } from 'soapbox/actions/auth';
|
||||
import * as BuildConfig from 'soapbox/build-config';
|
||||
import entities from 'soapbox/entity-store/reducer';
|
||||
|
||||
import accounts_meta from './accounts-meta';
|
||||
|
@ -119,29 +117,4 @@ const reducers = {
|
|||
user_lists,
|
||||
};
|
||||
|
||||
const appReducer = combineReducers(reducers);
|
||||
|
||||
type AppState = ReturnType<typeof appReducer>;
|
||||
|
||||
// Clear the state (mostly) when the user logs out
|
||||
const logOut = (state: AppState): ReturnType<typeof appReducer> => {
|
||||
if (BuildConfig.NODE_ENV === 'production') {
|
||||
location.href = '/login';
|
||||
}
|
||||
|
||||
const newState = rootReducer(undefined, { type: '' });
|
||||
|
||||
const { instance, soapbox, custom_emojis, auth } = state;
|
||||
return { ...newState, instance, soapbox, custom_emojis, auth };
|
||||
};
|
||||
|
||||
const rootReducer: typeof appReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case AUTH_LOGGED_OUT:
|
||||
return appReducer(logOut(state as AppState), action);
|
||||
default:
|
||||
return appReducer(state, action);
|
||||
}
|
||||
};
|
||||
|
||||
export default appReducer;
|
||||
export default combineReducers(reducers);
|
|
@ -44,6 +44,7 @@ describe('instance reducer', () => {
|
|||
expect(state.registrations).toBe(false);
|
||||
|
||||
// After importing the configs, registration will be open
|
||||
// @ts-ignore don't know why the type is not working
|
||||
const result = reducer(state, action);
|
||||
expect(result.registrations).toBe(true);
|
||||
});
|
||||
|
|
|
@ -2,34 +2,14 @@ import { produce } from 'immer';
|
|||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||
|
||||
import { ADMIN_CONFIG_UPDATE_REQUEST, ADMIN_CONFIG_UPDATE_SUCCESS } from 'soapbox/actions/admin';
|
||||
import { PLEROMA_PRELOAD_IMPORT } from 'soapbox/actions/preload';
|
||||
import { type Instance, instanceSchema } from 'soapbox/schemas';
|
||||
import KVStore from 'soapbox/storage/kv-store';
|
||||
import { InstanceV2, instanceV2Schema } from 'soapbox/schemas/instance';
|
||||
import { ConfigDB } from 'soapbox/utils/config-db';
|
||||
|
||||
import {
|
||||
fetchInstance,
|
||||
fetchInstanceV2,
|
||||
} from '../actions/instance';
|
||||
import { fetchInstanceV2 } from '../actions/instance';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const initialState: Instance = instanceSchema.parse({});
|
||||
|
||||
const importInstance = (_state: Instance, instance: APIEntity): Instance => {
|
||||
return instanceSchema.parse(instance);
|
||||
};
|
||||
|
||||
const importInstanceV2 = (state: Instance, data: APIEntity): Instance => {
|
||||
const instance = instanceSchema.parse(data);
|
||||
return { ...instance, stats: state.stats };
|
||||
};
|
||||
|
||||
const preloadImport = (state: Instance, action: Record<string, any>, path: string) => {
|
||||
const instance = action.data[path];
|
||||
return instance ? importInstance(state, instance) : state;
|
||||
};
|
||||
const initialState: InstanceV2 = instanceV2Schema.parse({});
|
||||
|
||||
const getConfigValue = (instanceConfig: ImmutableMap<string, any>, key: string) => {
|
||||
const v = instanceConfig
|
||||
|
@ -38,7 +18,7 @@ const getConfigValue = (instanceConfig: ImmutableMap<string, any>, key: string)
|
|||
return v ? v.getIn(['tuple', 1]) : undefined;
|
||||
};
|
||||
|
||||
const importConfigs = (state: Instance, configs: ImmutableList<any>) => {
|
||||
const importConfigs = (state: InstanceV2, configs: ImmutableList<any>) => {
|
||||
// FIXME: This is pretty hacked together. Need to make a cleaner map.
|
||||
const config = ConfigDB.find(configs, ':pleroma', ':instance');
|
||||
const simplePolicy = ConfigDB.toSimplePolicy(configs);
|
||||
|
@ -63,60 +43,10 @@ const importConfigs = (state: Instance, configs: ImmutableList<any>) => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleAuthFetch = (state: Instance) => {
|
||||
// Authenticated fetch is enabled, so make the instance appear censored
|
||||
return {
|
||||
...state,
|
||||
title: state.title || '██████',
|
||||
description: state.description || '████████████',
|
||||
};
|
||||
};
|
||||
|
||||
const getHost = (instance: { uri?: string; domain?: string }) => {
|
||||
const domain = instance.uri || instance.domain as string;
|
||||
try {
|
||||
return new URL(domain).host;
|
||||
} catch {
|
||||
try {
|
||||
return new URL(`https://${domain}`).host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const persistInstance = ({ instance }: { instance: { uri: string } }, host: string | null = getHost(instance)) => {
|
||||
if (host) {
|
||||
KVStore.setItem(`instance:${host}`, instance).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
const persistInstanceV2 = ({ instance }: { instance: { domain: string } }, host: string | null = getHost(instance)) => {
|
||||
if (host) {
|
||||
KVStore.setItem(`instanceV2:${host}`, instance).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstanceFetchFail = (state: Instance, error: Record<string, any>) => {
|
||||
if (error.response?.status === 401) {
|
||||
return handleAuthFetch(state);
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default function instance(state = initialState, action: AnyAction) {
|
||||
export default function instance(state = initialState, action: AnyAction): InstanceV2 {
|
||||
switch (action.type) {
|
||||
case PLEROMA_PRELOAD_IMPORT:
|
||||
return preloadImport(state, action, '/api/v1/instance');
|
||||
case fetchInstance.fulfilled.type:
|
||||
persistInstance(action.payload);
|
||||
return importInstance(state, action.payload.instance);
|
||||
case fetchInstanceV2.fulfilled.type:
|
||||
persistInstanceV2(action.payload);
|
||||
return importInstanceV2(state, action.payload.instance);
|
||||
case fetchInstance.rejected.type:
|
||||
return handleInstanceFetchFail(state, action.error);
|
||||
return action.payload.instance;
|
||||
case ADMIN_CONFIG_UPDATE_REQUEST:
|
||||
case ADMIN_CONFIG_UPDATE_SUCCESS:
|
||||
return importConfigs(state, ImmutableList(fromJS(action.configs)));
|
||||
|
|
|
@ -12,7 +12,6 @@ export { groupSchema, type Group } from './group';
|
|||
export { groupMemberSchema, type GroupMember } from './group-member';
|
||||
export { groupRelationshipSchema, type GroupRelationship } from './group-relationship';
|
||||
export { groupTagSchema, type GroupTag } from './group-tag';
|
||||
export { instanceSchema, type Instance } from './instance';
|
||||
export { mentionSchema, type Mention } from './mention';
|
||||
export { moderationLogEntrySchema, type ModerationLogEntry } from './moderation-log-entry';
|
||||
export { notificationSchema, type Notification } from './notification';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { instanceSchema } from './instance';
|
||||
import { instanceV1Schema } from './instance';
|
||||
|
||||
describe('instanceSchema.parse()', () => {
|
||||
describe('instanceV1Schema.parse()', () => {
|
||||
it('normalizes an empty Map', () => {
|
||||
const expected = {
|
||||
configuration: {
|
||||
|
@ -67,7 +67,7 @@ describe('instanceSchema.parse()', () => {
|
|||
version: '0.0.0',
|
||||
};
|
||||
|
||||
const result = instanceSchema.parse({});
|
||||
const result = instanceV1Schema.parse({});
|
||||
expect(result).toMatchObject(expected);
|
||||
});
|
||||
|
||||
|
@ -89,7 +89,7 @@ describe('instanceSchema.parse()', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const result = instanceSchema.parse(instance);
|
||||
const result = instanceV1Schema.parse(instance);
|
||||
expect(result).toMatchObject(expected);
|
||||
});
|
||||
|
||||
|
@ -119,7 +119,7 @@ describe('instanceSchema.parse()', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const result = instanceSchema.parse(instance);
|
||||
const result = instanceV1Schema.parse(instance);
|
||||
expect(result).toMatchObject(expected);
|
||||
});
|
||||
|
||||
|
@ -141,13 +141,13 @@ describe('instanceSchema.parse()', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const result = instanceSchema.parse(instance);
|
||||
const result = instanceV1Schema.parse(instance);
|
||||
expect(result).toMatchObject(expected);
|
||||
});
|
||||
|
||||
it('normalizes Fedibird instance', () => {
|
||||
const instance = require('soapbox/__fixtures__/fedibird-instance.json');
|
||||
const result = instanceSchema.parse(instance);
|
||||
const result = instanceV1Schema.parse(instance);
|
||||
|
||||
// Sets description_limit
|
||||
expect(result.pleroma.metadata.description_limit).toEqual(1500);
|
||||
|
@ -158,7 +158,7 @@ describe('instanceSchema.parse()', () => {
|
|||
|
||||
it('normalizes Mitra instance', () => {
|
||||
const instance = require('soapbox/__fixtures__/mitra-instance.json');
|
||||
const result = instanceSchema.parse(instance);
|
||||
const result = instanceV1Schema.parse(instance);
|
||||
|
||||
// Adds configuration and description_limit
|
||||
expect(result.configuration).toBeTruthy();
|
||||
|
@ -167,7 +167,7 @@ describe('instanceSchema.parse()', () => {
|
|||
|
||||
it('normalizes GoToSocial instance', () => {
|
||||
const instance = require('soapbox/__fixtures__/gotosocial-instance.json');
|
||||
const result = instanceSchema.parse(instance);
|
||||
const result = instanceV1Schema.parse(instance);
|
||||
|
||||
// Normalizes max_toot_chars
|
||||
expect(result.configuration.statuses.max_characters).toEqual(5000);
|
||||
|
@ -180,7 +180,7 @@ describe('instanceSchema.parse()', () => {
|
|||
|
||||
it('normalizes Friendica instance', () => {
|
||||
const instance = require('soapbox/__fixtures__/friendica-instance.json');
|
||||
const result = instanceSchema.parse(instance);
|
||||
const result = instanceV1Schema.parse(instance);
|
||||
|
||||
// Normalizes max_toot_chars
|
||||
expect(result.configuration.statuses.max_characters).toEqual(200000);
|
||||
|
@ -193,20 +193,20 @@ describe('instanceSchema.parse()', () => {
|
|||
|
||||
it('normalizes a Mastodon RC version', () => {
|
||||
const instance = require('soapbox/__fixtures__/mastodon-instance-rc.json');
|
||||
const result = instanceSchema.parse(instance);
|
||||
const result = instanceV1Schema.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);
|
||||
const result = instanceV1Schema.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);
|
||||
const result = instanceV1Schema.parse(instance);
|
||||
|
||||
expect(result.version).toEqual('2.7.2 (compatible; Pleroma 2.4.50+akkoma)');
|
||||
|
||||
|
|
|
@ -1,16 +1,12 @@
|
|||
/* 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) => {
|
||||
const versionSchema = z.string().catch('0.0.0').transform((version) => {
|
||||
// Handle Mastodon release candidates
|
||||
if (new RegExp(/[0-9.]+rc[0-9]+/g).test(version)) {
|
||||
version = version.split('rc').join('-rc');
|
||||
|
@ -27,16 +23,20 @@ const fixVersion = (version: string) => {
|
|||
}
|
||||
|
||||
return version;
|
||||
};
|
||||
});
|
||||
|
||||
const configurationSchema = coerceObject({
|
||||
accounts: coerceObject({
|
||||
max_featured_tags: z.number().catch(Infinity),
|
||||
max_pinned_statuses: z.number().catch(Infinity),
|
||||
}),
|
||||
chats: coerceObject({
|
||||
max_characters: z.number().catch(5000),
|
||||
max_media_attachments: z.number().catch(1),
|
||||
max_characters: z.number().catch(Infinity),
|
||||
max_media_attachments: z.number().catch(Infinity),
|
||||
}),
|
||||
groups: coerceObject({
|
||||
max_characters_description: z.number().catch(160),
|
||||
max_characters_name: z.number().catch(50),
|
||||
max_characters_description: z.number().catch(Infinity),
|
||||
max_characters_name: z.number().catch(Infinity),
|
||||
}),
|
||||
media_attachments: coerceObject({
|
||||
image_matrix_limit: z.number().optional().catch(undefined),
|
||||
|
@ -48,18 +48,18 @@ const configurationSchema = coerceObject({
|
|||
video_size_limit: z.number().optional().catch(undefined),
|
||||
}),
|
||||
polls: coerceObject({
|
||||
max_characters_per_option: z.number().optional().catch(undefined),
|
||||
max_expiration: z.number().optional().catch(undefined),
|
||||
max_options: z.number().optional().catch(undefined),
|
||||
min_expiration: z.number().optional().catch(undefined),
|
||||
max_characters_per_option: z.number().catch(Infinity),
|
||||
max_expiration: z.number().catch(Infinity),
|
||||
max_options: z.number().catch(Infinity),
|
||||
min_expiration: z.number().catch(Infinity),
|
||||
}),
|
||||
reactions: 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),
|
||||
max_characters: z.number().catch(Infinity),
|
||||
max_media_attachments: z.number().catch(Infinity),
|
||||
|
||||
}),
|
||||
translation: coerceObject({
|
||||
|
@ -68,11 +68,14 @@ const configurationSchema = coerceObject({
|
|||
urls: coerceObject({
|
||||
streaming: z.string().url().optional().catch(undefined),
|
||||
}),
|
||||
vapid: coerceObject({
|
||||
public_key: z.string().optional().catch(undefined),
|
||||
}),
|
||||
});
|
||||
|
||||
const contactSchema = coerceObject({
|
||||
contact_account: accountSchema.optional().catch(undefined),
|
||||
email: z.string().email().catch(''),
|
||||
account: accountSchema.optional().catch(undefined),
|
||||
email: z.string().email().optional().catch(undefined),
|
||||
});
|
||||
|
||||
const nostrSchema = coerceObject({
|
||||
|
@ -138,14 +141,7 @@ const pleromaSchema = coerceObject({
|
|||
vapid_public_key: z.string().catch(''),
|
||||
});
|
||||
|
||||
const pleromaPollLimitsSchema = coerceObject({
|
||||
max_expiration: z.number().optional().catch(undefined),
|
||||
max_option_chars: z.number().optional().catch(undefined),
|
||||
max_options: z.number().optional().catch(undefined),
|
||||
min_expiration: z.number().optional().catch(undefined),
|
||||
});
|
||||
|
||||
const registrations = coerceObject({
|
||||
const registrationsSchema = coerceObject({
|
||||
approval_required: z.boolean().catch(false),
|
||||
enabled: z.boolean().catch(false),
|
||||
message: z.string().optional().catch(undefined),
|
||||
|
@ -158,12 +154,22 @@ const statsSchema = coerceObject({
|
|||
});
|
||||
|
||||
const thumbnailSchema = coerceObject({
|
||||
url: z.string().catch(''),
|
||||
blurhash: z.string().optional().catch(undefined),
|
||||
url: z.string().url().optional().catch(undefined),
|
||||
versions: coerceObject({
|
||||
'@1x': z.string().url().optional().catch(undefined),
|
||||
'@2x': z.string().url().optional().catch(undefined),
|
||||
}),
|
||||
});
|
||||
|
||||
const instanceIconSchema = coerceObject({
|
||||
size: z.string().optional().catch(undefined),
|
||||
src: z.string().url().optional().catch(undefined),
|
||||
});
|
||||
|
||||
const usageSchema = coerceObject({
|
||||
users: coerceObject({
|
||||
active_month: z.number().catch(0),
|
||||
active_month: z.number().optional().catch(undefined),
|
||||
}),
|
||||
});
|
||||
|
||||
|
@ -176,12 +182,11 @@ const instanceV1Schema = coerceObject({
|
|||
email: z.string().email().catch(''),
|
||||
feature_quote: z.boolean().catch(false),
|
||||
fedibird_capabilities: z.array(z.string()).catch([]),
|
||||
languages: z.string().array().catch([]),
|
||||
languages: filteredArray(z.string()),
|
||||
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(''),
|
||||
|
@ -192,118 +197,62 @@ const instanceV1Schema = coerceObject({
|
|||
urls: coerceObject({
|
||||
streaming_api: z.string().url().optional().catch(undefined),
|
||||
}),
|
||||
usage: usageSchema,
|
||||
version: z.string().catch('0.0.0'),
|
||||
version: versionSchema,
|
||||
});
|
||||
|
||||
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,
|
||||
uri,
|
||||
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,
|
||||
domain: uri,
|
||||
pleroma: {
|
||||
...pleroma,
|
||||
metadata: {
|
||||
...pleroma.metadata,
|
||||
description_limit,
|
||||
},
|
||||
},
|
||||
registrations: {
|
||||
approval_required: approval_required,
|
||||
enabled: registrations,
|
||||
},
|
||||
thumbnail: { url: thumbnail },
|
||||
};
|
||||
}, coerceObject({
|
||||
const instanceV2Schema = coerceObject({
|
||||
api_versions: z.record(z.string(), z.number()).catch({}),
|
||||
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([]),
|
||||
icon: filteredArray(instanceIconSchema),
|
||||
languages: filteredArray(z.string()),
|
||||
nostr: nostrSchema.optional().catch(undefined),
|
||||
pleroma: pleromaSchema,
|
||||
registrations: registrations,
|
||||
registrations: registrationsSchema,
|
||||
rules: filteredArray(ruleSchema),
|
||||
stats: statsSchema,
|
||||
source_url: z.string().url().optional().catch(undefined),
|
||||
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 ?? 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 ?? 500,
|
||||
max_media_attachments: configuration.statuses.max_media_attachments ?? 4,
|
||||
};
|
||||
version: versionSchema,
|
||||
});
|
||||
|
||||
function upgradeInstance(v1: InstanceV1): InstanceV2 {
|
||||
return {
|
||||
...instance,
|
||||
configuration: {
|
||||
...configuration,
|
||||
polls,
|
||||
statuses,
|
||||
api_versions: {},
|
||||
configuration: v1.configuration,
|
||||
contact: {
|
||||
account: v1.contact_account,
|
||||
email: v1.email,
|
||||
},
|
||||
version,
|
||||
description: v1.short_description,
|
||||
domain: v1.uri,
|
||||
icon: [],
|
||||
languages: v1.languages,
|
||||
nostr: v1.nostr,
|
||||
pleroma: v1.pleroma,
|
||||
registrations: {
|
||||
approval_required: v1.approval_required,
|
||||
enabled: v1.registrations,
|
||||
},
|
||||
rules: v1.rules,
|
||||
thumbnail: {
|
||||
url: v1.thumbnail,
|
||||
versions: {
|
||||
'@1x': v1.thumbnail,
|
||||
},
|
||||
},
|
||||
title: v1.title,
|
||||
usage: {
|
||||
users: {},
|
||||
},
|
||||
version: v1.version,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
type Instance = z.infer<typeof instanceSchema>;
|
||||
type InstanceV1 = z.infer<typeof instanceV1Schema>;
|
||||
type InstanceV2 = z.infer<typeof instanceV2Schema>;
|
||||
|
||||
export { instanceSchema, Instance };
|
||||
export { instanceV1Schema, InstanceV1, instanceV2Schema, InstanceV2, upgradeInstance };
|
||||
|
|
|
@ -6,7 +6,7 @@ import lt from 'semver/functions/lt';
|
|||
import semverParse from 'semver/functions/parse';
|
||||
|
||||
import { custom } from 'soapbox/custom';
|
||||
import { type Instance } from 'soapbox/schemas';
|
||||
import { InstanceV1, InstanceV2 } from 'soapbox/schemas/instance';
|
||||
|
||||
/** Import custom overrides, if exists */
|
||||
const overrides = custom('features');
|
||||
|
@ -102,7 +102,7 @@ export const REBASED = 'soapbox';
|
|||
export const UNRELEASED = 'unreleased';
|
||||
|
||||
/** Parse features for the given instance */
|
||||
const getInstanceFeatures = (instance: Instance) => {
|
||||
const getInstanceFeatures = (instance: InstanceV1 | InstanceV2) => {
|
||||
const v = parseVersion(instance.version);
|
||||
const { features, federation } = instance.pleroma.metadata;
|
||||
|
||||
|
@ -912,7 +912,7 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
v.software === FRIENDICA && gte(v.version, '2023.3.0'),
|
||||
v.software === PLEROMA && [REBASED, AKKOMA].includes(v.build!) && gte(v.version, '2.4.50'),
|
||||
features.includes('quote_posting'),
|
||||
instance.feature_quote === true,
|
||||
'feature_quote' in instance && instance.feature_quote === true,
|
||||
]),
|
||||
|
||||
/**
|
||||
|
@ -1087,7 +1087,7 @@ export type Features = ReturnType<typeof getInstanceFeatures>;
|
|||
|
||||
/** Detect backend features to conditionally render elements */
|
||||
export const getFeatures = createSelector([
|
||||
(instance: Instance) => instance,
|
||||
(instance: InstanceV1 | InstanceV2) => instance,
|
||||
], (instance): Features => {
|
||||
const features = getInstanceFeatures(instance);
|
||||
return Object.assign(features, overrides) as Features;
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
/* eslint sort-keys: "error" */
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { parseVersion, PLEROMA, MITRA } from './features';
|
||||
|
||||
import type { Instance } from 'soapbox/schemas';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
/** For solving bugs between API implementations. */
|
||||
export const getQuirks = createSelector([
|
||||
(instance: Instance) => parseVersion(instance.version),
|
||||
], (v) => {
|
||||
return {
|
||||
/**
|
||||
* The `next` and `prev` Link headers are backwards for blocks and mutes.
|
||||
* @see GET /api/v1/blocks
|
||||
* @see GET /api/v1/mutes
|
||||
*/
|
||||
invertedPagination: v.software === PLEROMA,
|
||||
|
||||
/**
|
||||
* Apps are not supported by the API, and should not be created during login or registration.
|
||||
* @see POST /api/v1/apps
|
||||
* @see POST /oauth/token
|
||||
*/
|
||||
noApps: v.software === MITRA,
|
||||
};
|
||||
});
|
||||
|
||||
/** Shortcut for inverted pagination quirk. */
|
||||
export const getNextLinkName = (getState: () => RootState) =>
|
||||
getQuirks(getState().instance).invertedPagination ? 'prev' : 'next';
|
|
@ -1,15 +1,14 @@
|
|||
|
||||
import { PLEROMA, parseVersion } from './features';
|
||||
|
||||
import type { Instance } from 'soapbox/schemas';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
/**
|
||||
* Get the OAuth scopes to use for login & signup.
|
||||
* Mastodon will refuse scopes it doesn't know, so care is needed.
|
||||
*/
|
||||
const getInstanceScopes = (instance: Instance) => {
|
||||
const v = parseVersion(instance.version);
|
||||
const getInstanceScopes = (version: string) => {
|
||||
const v = parseVersion(version);
|
||||
|
||||
switch (v.software) {
|
||||
case PLEROMA:
|
||||
|
@ -20,7 +19,7 @@ const getInstanceScopes = (instance: Instance) => {
|
|||
};
|
||||
|
||||
/** Convenience function to get scopes from instance in store. */
|
||||
const getScopes = (state: RootState) => getInstanceScopes(state.instance);
|
||||
const getScopes = (state: RootState) => getInstanceScopes(state.instance.version);
|
||||
|
||||
export {
|
||||
getInstanceScopes,
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import * as BuildConfig from 'soapbox/build-config';
|
||||
import { isPrerendered } from 'soapbox/precheck';
|
||||
import { selectOwnAccount } from 'soapbox/selectors';
|
||||
import { isURL } from 'soapbox/utils/auth';
|
||||
|
||||
|
@ -21,15 +20,6 @@ export const federationRestrictionsDisclosed = (state: RootState): boolean => {
|
|||
return !!state.instance.pleroma.metadata.federation.mrf_policies;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine whether Soapbox is running in standalone mode.
|
||||
* Standalone mode runs separately from any backend and can login anywhere.
|
||||
*/
|
||||
export const isStandalone = (state: RootState): boolean => {
|
||||
const instanceFetchFailed = state.meta.instance_fetch_failed;
|
||||
return isURL(BuildConfig.BACKEND_URL) ? false : (!isPrerendered && instanceFetchFailed);
|
||||
};
|
||||
|
||||
const getHost = (url: any): string => {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
|
|
Ładowanie…
Reference in New Issue