diff --git a/src/actions/auth.ts b/src/actions/auth.ts index bd5dd726b..e1e5fddfe 100644 --- a/src/actions/auth.ts +++ b/src/actions/auth.ts @@ -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); }); diff --git a/src/actions/blocks.ts b/src/actions/blocks.ts index 58641b5c8..b672e778f 100644 --- a/src/actions/blocks.ts +++ b/src/actions/blocks.ts @@ -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); diff --git a/src/actions/external-auth.ts b/src/actions/external-auth.ts index 45edb8ba5..45d288bc5 100644 --- a/src/actions/external-auth.ts +++ b/src/actions/external-auth.ts @@ -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; diff --git a/src/actions/instance.ts b/src/actions/instance.ts index 510fb052c..dd17af6ef 100644 --- a/src/actions/instance.ts +++ b/src/actions/instance.ts @@ -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 { 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 { 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); diff --git a/src/api/hooks/groups/useGroups.test.ts b/src/api/hooks/groups/useGroups.test.ts index 65a699969..c29d6eb09 100644 --- a/src/api/hooks/groups/useGroups.test.ts +++ b/src/api/hooks/groups/useGroups.test.ts @@ -1,13 +1,13 @@ import { __stub } from 'soapbox/api'; import { buildGroup } from 'soapbox/jest/factory'; import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; -import { 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)', }), }; diff --git a/src/api/hooks/groups/usePendingGroups.test.ts b/src/api/hooks/groups/usePendingGroups.test.ts index 33190ae0b..a590360c3 100644 --- a/src/api/hooks/groups/usePendingGroups.test.ts +++ b/src/api/hooks/groups/usePendingGroups.test.ts @@ -2,14 +2,14 @@ import { __stub } from 'soapbox/api'; import { Entities } from 'soapbox/entity-store/entities'; import { buildAccount, buildGroup } from 'soapbox/jest/factory'; import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; -import { 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', diff --git a/src/api/hooks/instance/useInstanceV1.ts b/src/api/hooks/instance/useInstanceV1.ts new file mode 100644 index 000000000..73fa757d3 --- /dev/null +++ b/src/api/hooks/instance/useInstanceV1.ts @@ -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, '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({ + 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 }; +} diff --git a/src/api/hooks/instance/useInstanceV2.ts b/src/api/hooks/instance/useInstanceV2.ts new file mode 100644 index 000000000..8e9b8ab24 --- /dev/null +++ b/src/api/hooks/instance/useInstanceV2.ts @@ -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, '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({ + 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 }; +} diff --git a/src/features/admin/components/registration-mode-picker.tsx b/src/features/admin/components/registration-mode-picker.tsx index d7762dfe4..8cd6959bf 100644 --- a/src/features/admin/components/registration-mode-picker.tsx +++ b/src/features/admin/components/registration-mode-picker.tsx @@ -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'; }; diff --git a/src/features/admin/tabs/dashboard.tsx b/src/features/admin/tabs/dashboard.tsx index bfba473ad..8a8b65d0b 100644 --- a/src/features/admin/tabs/dashboard.tsx +++ b/src/features/admin/tabs/dashboard.tsx @@ -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; diff --git a/src/features/auth-login/components/login-form.test.tsx b/src/features/auth-login/components/login-form.test.tsx index 2cecaa059..cef110b44 100644 --- a/src/features/auth-login/components/login-form.test.tsx +++ b/src/features/auth-login/components/login-form.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { fireEvent, render, screen } from 'soapbox/jest/test-helpers'; -import { instanceSchema } from 'soapbox/schemas'; +import { instanceV1Schema } from 'soapbox/schemas/instance'; import LoginForm from './login-form'; @@ -9,7 +9,7 @@ describe('', () => { 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('', () => { it('renders for Mastodon', () => { const mockFn = vi.fn(); const store = { - instance: instanceSchema.parse({ + instance: instanceV1Schema.parse({ version: '3.0.0', }), }; diff --git a/src/features/auth-login/components/login-page.test.tsx b/src/features/auth-login/components/login-page.test.tsx index fc308ff4f..dfae49747 100644 --- a/src/features/auth-login/components/login-page.test.tsx +++ b/src/features/auth-login/components/login-page.test.tsx @@ -1,14 +1,14 @@ import React from 'react'; import { render, screen } from 'soapbox/jest/test-helpers'; -import { instanceSchema } from 'soapbox/schemas'; +import { instanceV1Schema } from 'soapbox/schemas/instance'; import LoginPage from './login-page'; describe('', () => { it('renders correctly on load', () => { const store = { - instance: instanceSchema.parse({ + instance: instanceV1Schema.parse({ version: '2.7.2 (compatible; Pleroma 2.3.0)', }), }; diff --git a/src/features/auth-login/components/login-page.tsx b/src/features/auth-login/components/login-page.tsx index 9ceec4a55..50dc1ac83 100644 --- a/src/features/auth-login/components/login-page.tsx +++ b/src/features/auth-login/components/login-page.tsx @@ -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 ; } - if (standalone) return ; + if (instance.isNotFound) { + return ; + } if (shouldRedirect) { const redirectUri = getRedirectUrl(); diff --git a/src/features/groups/components/discover/search/search.test.tsx b/src/features/groups/components/discover/search/search.test.tsx index 5c25cc5a3..0ae0f8cd8 100644 --- a/src/features/groups/components/discover/search/search.test.tsx +++ b/src/features/groups/components/discover/search/search.test.tsx @@ -3,12 +3,12 @@ import React from 'react'; import { __stub } from 'soapbox/api'; import { buildGroup } from 'soapbox/jest/factory'; import { render, screen, waitFor } from 'soapbox/jest/test-helpers'; -import { 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)', }), }; diff --git a/src/features/groups/discover.test.tsx b/src/features/groups/discover.test.tsx index 880b17c1c..349999e7a 100644 --- a/src/features/groups/discover.test.tsx +++ b/src/features/groups/discover.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { buildAccount } from 'soapbox/jest/factory'; import { render, screen, waitFor } from 'soapbox/jest/test-helpers'; -import { 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', }), }; diff --git a/src/features/ui/components/navbar.tsx b/src/features/ui/components/navbar.tsx index 9af98cd8f..627236d6e 100644 --- a/src/features/ui/components/navbar.tsx +++ b/src/features/ui/components/navbar.tsx @@ -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 = () => { )} - {!standalone && ( + {instance.isSuccess && ( {account ? (
diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index 2fd5fac92..711f14289 100644 --- a/src/features/ui/index.tsx +++ b/src/features/ui/index.tsx @@ -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 = ({ 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 = ({ children }) => // Ex: use /login instead of /auth, but redirect /auth to /login return ( - {standalone && } + {isNotFound && } @@ -388,11 +386,11 @@ const UI: React.FC = ({ children }) => { const node = useRef(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 = ({ children }) => { - {!standalone && } + {instance.isSuccess && } diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts index 5a4babb02..b8b7dbf28 100644 --- a/src/hooks/useApi.ts +++ b/src/hooks/useApi.ts @@ -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]); } \ No newline at end of file diff --git a/src/hooks/useInstance.ts b/src/hooks/useInstance.ts index 62635553c..940fc2b6c 100644 --- a/src/hooks/useInstance.ts +++ b/src/hooks/useInstance.ts @@ -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, '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 }; +} diff --git a/src/init/soapbox-load.tsx b/src/init/soapbox-load.tsx index 423196c4f..97c0a7efc 100644 --- a/src/init/soapbox-load.tsx +++ b/src/init/soapbox-load.tsx @@ -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 = ({ 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 = ({ children }) => { me && !account, !isLoaded, localeLoading, + instance.isLoading, swUpdating, hasNostr && me && (!isRelayOpen || !isSubscribed), ].some(Boolean); @@ -68,12 +68,14 @@ const SoapboxLoad: React.FC = ({ 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. diff --git a/src/init/soapbox-mount.tsx b/src/init/soapbox-mount.tsx index d91c71c3e..71942395c 100644 --- a/src/init/soapbox-mount.tsx +++ b/src/init/soapbox-mount.tsx @@ -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 diff --git a/src/jest/factory.ts b/src/jest/factory.ts index 145da76b2..a2818c0c8 100644 --- a/src/jest/factory.ts +++ b/src/jest/factory.ts @@ -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 = {}) { - return instanceSchema.parse(props); +function buildInstance(props: PartialDeep = {}) { + return instanceV2Schema.parse(props); } function buildRelationship(props: PartialDeep = {}): Relationship { diff --git a/src/jest/mock-stores.tsx b/src/jest/mock-stores.tsx index c3ba64aac..638a7d4a9 100644 --- a/src/jest/mock-stores.tsx +++ b/src/jest/mock-stores.tsx @@ -1,13 +1,13 @@ import alexJson from 'soapbox/__fixtures__/pleroma-account.json'; -import { 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 = { diff --git a/src/main.tsx b/src/main.tsx index 6be862eae..78555853e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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'; diff --git a/src/precheck.ts b/src/precheck.ts deleted file mode 100644 index 03fea80a1..000000000 --- a/src/precheck.ts +++ /dev/null @@ -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; diff --git a/src/reducers/auth.ts b/src/reducers/auth.ts index af980a3a2..f066f3dbb 100644 --- a/src/reducers/auth.ts +++ b/src/reducers/auth.ts @@ -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(); } }; diff --git a/src/reducers/index.ts b/src/reducers/index.ts index a6df8e026..4534812dd 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -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; - -// Clear the state (mostly) when the user logs out -const logOut = (state: AppState): ReturnType => { - 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); \ No newline at end of file diff --git a/src/reducers/instance.test.ts b/src/reducers/instance.test.ts index 63b5cbebb..1f89d8c52 100644 --- a/src/reducers/instance.test.ts +++ b/src/reducers/instance.test.ts @@ -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); }); diff --git a/src/reducers/instance.ts b/src/reducers/instance.ts index 3425ff37f..ad16cbfe8 100644 --- a/src/reducers/instance.ts +++ b/src/reducers/instance.ts @@ -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, path: string) => { - const instance = action.data[path]; - return instance ? importInstance(state, instance) : state; -}; +const initialState: InstanceV2 = instanceV2Schema.parse({}); const getConfigValue = (instanceConfig: ImmutableMap, key: string) => { const v = instanceConfig @@ -38,7 +18,7 @@ const getConfigValue = (instanceConfig: ImmutableMap, key: string) return v ? v.getIn(['tuple', 1]) : undefined; }; -const importConfigs = (state: Instance, configs: ImmutableList) => { +const importConfigs = (state: InstanceV2, configs: ImmutableList) => { // 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) => { }); }; -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) => { - 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))); diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 3e8d00f7f..253baff7a 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -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'; diff --git a/src/schemas/instance.test.ts b/src/schemas/instance.test.ts index a47e1adad..fd1b6a37d 100644 --- a/src/schemas/instance.test.ts +++ b/src/schemas/instance.test.ts @@ -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)'); diff --git a/src/schemas/instance.ts b/src/schemas/instance.ts index 081f2f493..90eaa906a 100644 --- a/src/schemas/instance.ts +++ b/src/schemas/instance.ts @@ -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; +type InstanceV1 = z.infer; +type InstanceV2 = z.infer; -export { instanceSchema, Instance }; +export { instanceV1Schema, InstanceV1, instanceV2Schema, InstanceV2, upgradeInstance }; diff --git a/src/utils/features.ts b/src/utils/features.ts index 2bbc3914b..d30905225 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -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; /** 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; diff --git a/src/utils/quirks.ts b/src/utils/quirks.ts deleted file mode 100644 index 9024c67be..000000000 --- a/src/utils/quirks.ts +++ /dev/null @@ -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'; diff --git a/src/utils/scopes.ts b/src/utils/scopes.ts index 9a6175952..27712c0c3 100644 --- a/src/utils/scopes.ts +++ b/src/utils/scopes.ts @@ -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, diff --git a/src/utils/state.ts b/src/utils/state.ts index f2cf27660..27dbcf33b 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -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;